// === LIOHER LIO CHATBOT === (function() { var link = document.createElement('link'); link.rel = 'stylesheet'; link.href = 'https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&family=DM+Sans:wght@300;400;500&display=swap'; document.head.appendChild(link); var style = document.createElement('style'); style.textContent = "* { margin: 0; padding: 0; box-sizing: border-box; }\n body { font-family: 'Montserrat', sans-serif; background: #edeae5; min-height: 100vh; }\n\n #lioher-launcher {\n position: fixed; bottom: 28px; right: 28px; width: 60px; height: 60px;\n background: #0a0a0a; border-radius: 50%;\n cursor: pointer; display: flex; align-items: center; justify-content: center;\n box-shadow: 0 10px 30px rgba(0,0,0,0.3);\n transition: transform 0.3s cubic-bezier(.34,1.56,.64,1); z-index: 9999;\n }\n #lioher-launcher:hover { transform: scale(1.08); }\n .icon-close { opacity: 0; position: absolute; transition: opacity 0.2s; }\n .icon-chat { transition: opacity 0.2s; }\n #lioher-launcher.open .icon-chat { opacity: 0; }\n #lioher-launcher.open .icon-close { opacity: 1; }\n\n #lioher-chat {\n position: fixed; bottom: 104px; right: 28px; width: 400px; height: 600px;\n max-height: calc(100vh - 140px);\n background: #ffffff; border-radius: 18px;\n display: flex; flex-direction: column; overflow: hidden;\n box-shadow: 0 30px 80px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04); z-index: 9998;\n opacity: 0; transform: translateY(16px) scale(0.96); pointer-events: none;\n transition: opacity 0.3s cubic-bezier(.4,0,.2,1), transform 0.3s cubic-bezier(.4,0,.2,1);\n }\n #lioher-chat.open { opacity: 1; transform: translateY(0) scale(1); pointer-events: all; }\n\n /* HEADER: minimize · title · close (Tesla-style) */\n .chat-header {\n padding: 16px 18px; border-bottom: 1px solid #eeeae2; background: #ffffff;\n display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;\n }\n .header-btn { width: 26px; height: 26px; border: none; background: none; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #555; border-radius: 50%; transition: background 0.15s; }\n .header-btn:hover { background: #f2ede4; }\n .chat-header-title { font-family: 'Montserrat', sans-serif; font-size: 11px; font-weight: 600; color: #1a1a1a; letter-spacing: -0.005em; }\n\n /* Legacy classes (kept to avoid breaking JS) */\n .chat-header-info { display: none; }\n .chat-header-name { display: none; }\n .chat-header-status { display: none; }\n .chat-header-logo { display: none; }\n .chat-avatar { display: none; }\n\n .chat-messages {\n flex: 1; overflow-y: auto; padding: 20px 18px;\n display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth;\n background: #ffffff;\n }\n .chat-messages::-webkit-scrollbar { width: 4px; }\n .chat-messages::-webkit-scrollbar-thumb { background: #e0dcd2; border-radius: 2px; }\n\n /* MESSAGES with inline avatar for agent (via ::before) */\n .msg { max-width: 85%; animation: fadeUp 0.25s ease; position: relative; }\n @keyframes fadeUp { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }\n .msg.agent { align-self: flex-start; padding-left: 46px; }\n .msg.user { align-self: flex-end; max-width: 78%; }\n .msg.agent::before {\n content: ''; position: absolute; left: 0; top: 2px;\n width: 36px; height: 36px; border-radius: 50%;\n background: #0a0a0a url('https://lioher.com/wp-content/uploads/2026/04/lioher-logo-white.png') center/22px no-repeat;\n }\n .msg-bubble { padding: 10px 14px; border-radius: 14px; font-size: 11.5px; font-weight: 400; line-height: 1.5; letter-spacing: 0.002em; white-space: pre-line; }\n .msg.agent .msg-bubble { background: #f2f2f2; color: #1a1a1a; }\n .msg.user .msg-bubble { background: #3E6AE1; color: #fff; }\n .msg-time { font-size: 9px; color: #bbb; margin-top: 4px; padding: 0 3px; }\n .msg.user .msg-time { text-align: right; }\n\n /* CHIPS (Tesla-clean, not blue) */\n .quick-replies { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }\n .qr-chip { padding: 5px 11px; background: #ffffff; border: 1px solid #d9d5cc; border-radius: 16px; font-size: 10.5px; font-weight: 500; color: #1a1a1a; cursor: pointer; transition: all 0.15s; font-family: 'Montserrat', sans-serif; letter-spacing: 0.01em; }\n .qr-chip:hover { border-color: #1a1a1a; background: #f8f6f1; }\n\n /* MENU CARD */\n .menu-card { background: #ffffff; border: 1px solid #ecE8df; border-radius: 12px; padding: 10px; margin-top: 6px; display: flex; flex-direction: column; gap: 6px; animation: fadeUp 0.3s ease; }\n .menu-card-title { font-size: 9.5px; color: #888; letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 4px; font-weight: 600; padding: 2px 2px 0; }\n .menu-btn { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 10px 12px; border: 1px solid #e5e0d6; border-radius: 10px; background: #fafaf7; color: #1a1a1a; font-size: 10.5px; font-weight: 600; cursor: pointer; transition: all 0.15s; text-align: left; font-family: 'Montserrat', sans-serif; }\n .menu-btn:hover { background: #ffffff; border-color: #1a1a1a; }\n .menu-btn::after { content: '→'; color: #bbb; font-weight: 300; font-size: 13px; }\n .menu-btn.samples { background: #0a0a0a; color: #fff; border-color: #0a0a0a; justify-content: center; }\n .menu-btn.samples::after { display: none; }\n .menu-btn.samples:hover { background: #000; }\n\n /* BOOKING CARD */\n .booking-card { background: #ffffff; border: 1px solid #ecE8df; border-radius: 12px; padding: 12px; margin-top: 6px; animation: fadeUp 0.3s ease; }\n .booking-card-title { font-family: 'Montserrat', sans-serif; font-size: 11px; color: #1a1a1a; margin-bottom: 10px; font-weight: 600; }\n .booking-btn { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid #e5e0d6; background: #fafaf7; color: #1a1a1a; font-size: 10.5px; font-weight: 600; cursor: pointer; margin-bottom: 6px; transition: all 0.15s; text-decoration: none; font-family: 'Montserrat', sans-serif; }\n .booking-btn:last-child { margin-bottom: 0; }\n .booking-btn:hover { border-color: #1a1a1a; background: #fff; }\n .booking-btn.primary { background: #0a0a0a; color: #fff; border-color: #0a0a0a; justify-content: center; }\n .booking-btn.primary:hover { background: #000; }\n\n /* LEAD FORM (Tesla-style) */\n .lead-form { background: #ffffff; border: 1px solid #ecE8df; border-radius: 12px; padding: 14px; margin-top: 6px; animation: fadeUp 0.3s ease; }\n .lead-form-title { font-family: 'Montserrat', sans-serif; font-size: 11px; color: #1a1a1a; margin-bottom: 12px; font-weight: 600; letter-spacing: 0.01em; }\n .lead-form input { display: block; width: 100%; padding: 9px 11px; border: 1px solid #e0dcd2; border-radius: 8px; font-size: 11px; font-family: 'Montserrat', sans-serif; color: #1a1a1a; background: #fafaf7; margin-bottom: 8px; outline: none; transition: border-color 0.15s, background 0.15s; }\n .lead-form input:focus { border-color: #1a1a1a; background: #fff; }\n .lead-form input::placeholder { color: #a8a196; }\n .lead-form input.error { border-color: #e66; }\n .lead-form select, .lead-form textarea { display: block; width: 100%; padding: 9px 11px; border: 1px solid #e0dcd2; border-radius: 8px; font-size: 11px; font-family: 'Montserrat', sans-serif; color: #1a1a1a; background: #fafaf7; margin-bottom: 8px; outline: none; transition: border-color 0.15s, background 0.15s; }\n .lead-form textarea { resize: vertical; min-height: 44px; }\n .lead-form select:focus, .lead-form textarea:focus { border-color: #1a1a1a; background: #fff; }\n .lead-form select.error, .lead-form textarea.error { border-color: #e66; }\n .lead-form select:invalid { color: #a8a196; }\n .booking-alt { display: block; width: 100%; margin-top: 4px; padding: 4px 0; background: none; border: none; font-family: inherit; font-size: 10px; color: #5b7ebf; text-decoration: underline; cursor: pointer; text-align: center; }\n .lead-form-submit { width: 100%; padding: 10px; background: #0a0a0a; border: none; border-radius: 8px; font-size: 10.5px; font-family: 'Montserrat', sans-serif; font-weight: 600; color: #fff; cursor: pointer; transition: background 0.2s; margin-top: 4px; letter-spacing: 0.02em; }\n .lead-form-submit:hover { background: #000; }\n\n /* TYPING */\n .typing { display: flex; align-items: center; gap: 4px; padding: 10px 14px; }\n .typing span { width: 5px; height: 5px; background: #999; border-radius: 50%; animation: bounce 1.2s infinite; opacity: 0.5; }\n .typing span:nth-child(2) { animation-delay: 0.2s; }\n .typing span:nth-child(3) { animation-delay: 0.4s; }\n @keyframes bounce { 0%,80%,100%{transform:translateY(0);opacity:0.4} 40%{transform:translateY(-4px);opacity:1} }\n\n /* INPUT (Tesla-style, rounded pill) */\n .chat-input-area { padding: 12px 18px 16px; border-top: 1px solid #f0ece3; background: #fff; flex-shrink: 0; }\n .chat-input-row { display: flex; align-items: center; gap: 8px; background: #f6f4ef; border: 1px solid #ecE8df; border-radius: 14px; padding: 4px 4px 4px 16px; transition: border-color 0.15s; }\n .chat-input-row:focus-within { border-color: #c9c2b3; }\n #lioher-input { flex: 1; background: transparent; border: none; padding: 9px 0; color: #1a1a1a; font-size: 10.5px; font-weight: 400; font-family: 'Montserrat', sans-serif; resize: none; outline: none; max-height: 100px; line-height: 1.5; }\n #lioher-input::placeholder { color: #a8a196; }\n #lioher-send { width: 32px; height: 32px; background: #0a0a0a; border: none; border-radius: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.2s; flex-shrink: 0; color: #fff; }\n #lioher-send:hover { background: #000; }\n #lioher-send:disabled { opacity: 0.3; cursor: not-allowed; }\n .chat-footer { font-size: 9.5px; color: #9a928a; text-align: left; margin: 0 0 8px; letter-spacing: 0.01em; font-weight: 400; }\n\n @media (max-width: 440px) {\n #lioher-chat { width: calc(100vw - 16px); right: 8px; bottom: 88px; }\n #lioher-launcher { right: 14px; bottom: 14px; }\n }\n @media (max-width: 480px) {\n #lioher-chat { width: 100% !important; height: 100% !important; bottom: 0 !important; right: 0 !important; border-radius: 0 !important; top: 0 !important; }\n #lioher-launcher { bottom: 16px !important; right: 16px !important; width: 54px !important; height: 54px !important; }\n }\n\n @keyframes si-fadeIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }\n\n /* WEEK NAVIGATOR (date picker header) */\n .week-nav { display: flex; align-items: center; justify-content: space-between; padding: 4px 2px 8px; border-bottom: 1px solid #f0ece3; margin-bottom: 8px; }\n .week-nav-label { font-size: 10.5px; font-weight: 600; color: #1a1a1a; letter-spacing: 0.01em; }\n .week-arrow { width: 24px; height: 24px; border: none; background: none; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #555; border-radius: 50%; transition: background 0.15s, color 0.15s; font-family: inherit; }\n .week-arrow:hover { background: #f2ede4; color: #0a0a0a; }\n .week-arrow:disabled { color: #ddd; cursor: not-allowed; }\n .week-arrow svg { width: 12px; height: 12px; }\n\n /* INLINE BOOKING date/time chips */\n .booking-dates { display: flex; gap: 6px; flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; margin-bottom: 10px; }\n .booking-dates::-webkit-scrollbar { height: 3px; }\n .booking-dates::-webkit-scrollbar-thumb { background: #e0dcd2; border-radius: 2px; }\n .booking-date-btn { flex-shrink: 0; padding: 7px 10px; border-radius: 8px; border: 1px solid #e0dcd2; background: #fafaf7; font-size: 12px; font-weight: 700; color: #1a1a1a; cursor: pointer; font-family: inherit; transition: all 0.15s; text-align: center; min-width: 52px; }\n .booking-date-btn:hover { border-color: #1a1a1a; background: #fff; }\n .booking-date-btn.selected { background: #0a0a0a; color: #fff; border-color: #0a0a0a; }\n .booking-date-btn .date-day { display: block; font-size: 8.5px; font-weight: 500; color: #888; letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 2px; }\n .booking-date-btn.selected .date-day { color: #ccc; }\n .booking-times { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px; }\n .booking-time-btn { padding: 5px 10px; border-radius: 14px; border: 1px solid #e0dcd2; background: #fafaf7; font-size: 9.5px; font-weight: 500; color: #1a1a1a; cursor: pointer; font-family: inherit; transition: all 0.15s; }\n .booking-time-btn:hover { border-color: #1a1a1a; background: #fff; }\n .booking-time-btn.selected { background: #0a0a0a; color: #fff; border-color: #0a0a0a; }\n .booking-loading { font-size: 10px; color: #888; padding: 10px 0; text-align: center; }\n .booking-confirm { background: #fff; border: 1px solid #ecE8df; border-left: 3px solid #27ae60; border-radius: 10px; padding: 14px; margin-top: 6px; }\n .booking-confirm-title { font-size: 11px; font-weight: 700; color: #1a1a1a; margin-bottom: 8px; letter-spacing: 0.01em; }\n .booking-confirm-detail { font-size: 10.5px; color: #555; margin-bottom: 4px; }\n .booking-confirm-detail strong { color: #1a1a1a; }\n\n /* SUGGESTED CHIPS (in panel transcript) */\n .si-sug-chips { display:flex; flex-wrap:wrap; gap:5px; margin-top:6px; max-width:100%; }\n .si-sug-chip { display:inline-block; padding:4px 10px; background:#f3f0eb; border:1px solid #e0dcd4; border-radius:12px; font-size:10px; font-weight:500; color:#8a7f6f; letter-spacing:.02em; white-space:nowrap; }"; document.head.appendChild(style); })(); (function() { var d = document.createElement('div'); d.innerHTML = "
\n \n \n \n \n \n \n
\n\n
\n
\n \n
Lioher Assist
\n \n
\n
\n
\n
Lio uses AI, mistakes may occur.
\n
\n \n \n
\n
\n
"; while(d.firstChild) document.body.appendChild(d.firstChild); })(); (function() { // Lio's system prompt and knowledge base live only in n8n (AI Proxy, workflow 01; source: src/prompt/lio-prompt.txt). const PRODUCT_DATA = { 'Panels & Surfaces': { intro: 'Which surface collection interests you?', items: ['LUXE — High Gloss', 'ZENIT — Super Matt', 'SYNCRON — Texture', 'CLIMACORE — Outdoor', 'Essentials — TFL & Plywood'] }, 'Cabinet Doors': { intro: 'Which door style are you interested in?', items: ['Slab', 'Finger Pull (FP)', 'Mallorca — Recessed J-Handle', 'Cava — Classic Shaker', 'Slim Cava — NEW 2026'] }, 'Cabinetry': { intro: 'Which cabinetry line are you interested in?', items: ['Kitchens & Vanity', 'Outdoor Kitchens', 'Closets'] }, 'Other Products': { intro: 'What are you looking for?', items: ['Countertops', 'Slat Wall', 'Floating Shelves', 'Handles'] } }; // ── CONFIG ───────────────────────────────────────────────────────────────── const CONFIG = { aiProxy: 'https://lioher.app.n8n.cloud/webhook/lioher-ai-proxy', webhooks: { lead: 'https://lioher.app.n8n.cloud/webhook/lioher-lead', allChats: 'https://lioher.app.n8n.cloud/webhook/lioher-all-chats', followup: 'https://lioher.app.n8n.cloud/webhook/lioher-followup' }, appointments: { quickCall: 'https://usa-appointments.zohobookings.com/#/4336222000004023032', homeowner: 'https://usa-appointments.zohobookings.com/#/4336222000002845072', tradePro: 'https://usa-appointments.zohobookings.com/#/4336222000002828054', discoverySelection: 'https://lioher.com/discovery-selection-locations/' }, urls: { locations: 'https://lioher.com/location/', shop: 'https://shop.lioher.com', findPro: 'https://lioher.com/find-a-professional/', closets: 'https://lioher.com/closets/', closetConfigurator: 'https://17squares.com/Lioher/?designer=1', openAccount: 'https://webserv.lioher.com/privatearea/registration.php', designs: 'https://lioher.com/designs/', catalogs: 'https://lioher.com/inspiration/catalogs/', availabilityChart: 'https://lioher.com/wp-content/uploads/2026/04/Lioher-Availability-Chart-2026-2027-1.pdf', climacoreSpec: 'https://lioher.com/wp-content/uploads/2026/05/climacore-spec-sheet-1.pdf', doorsSpec: 'https://lioher.com/wp-content/uploads/2026/06/Lioher-Doors-Spec-Sheet.pdf' }, bookingProxy: 'https://lioher.app.n8n.cloud/webhook/lioher-slots', bookingCreate: 'https://lioher.app.n8n.cloud/webhook/lioher-book', serviceIds: { // Quick Calls quickCallEast: '4336222000004023032', quickCallWest: '4336222000004029014', closetConsult: '4336222000003158002', // Discovery & Selection (In-Person Showroom) showroom_miamiLakes: '4336222000001628178', showroom_doral: '4336222000001628236', showroom_pompano: '4336222000001628294', showroom_orlando: '4336222000001628352', showroom_tampa: '4336222000001628410', showroom_lasVegas: '4336222000001628468', showroom_vanNuys: '4336222000001628526', showroom_westPalm: '4336222000001628584', showroom_fortMyers: '4336222000002477004', // 1 Hour Kitchen Design kitchen_miamiLakes: '4336222000000122086', kitchen_vanNuys: '4336222000000383002', kitchen_pompano: '4336222000001304004', kitchen_tampa: '4336222000001308004', kitchen_doral: '4336222000001627012', kitchen_orlando: '4336222000001627070', kitchen_westPalm: '4336222000001627128', kitchen_lasVegas: '4336222000001627186', kitchen_fortMyers: '4336222000003558014', // Online Kitchen Design onlineProStudio: '4336222000002828054', onlineDesignStudio: '4336222000002845072', onlineKitchenDesign: '4336222000003912012' }, staffIds: { quickCallEast: '4336222000003457006', quickCallWest: '4336222000004029008', closetConsult: '4336222000003457006', showroom_miamiLakes: '4336222000000121032', showroom_doral: '4336222000000128006', showroom_pompano: '', showroom_orlando: '4336222000003009006', showroom_tampa: '4336222000000121080', showroom_lasVegas: '4336222000002148014', showroom_vanNuys: '4336222000001299008', showroom_westPalm: '4336222000000121068', showroom_fortMyers: '4336222000002480012', kitchen_miamiLakes: '4336222000002428006', kitchen_vanNuys: '4336222000001299008', kitchen_pompano: '4336222000001306006', kitchen_tampa: '4336222000002827006', kitchen_doral: '4336222000001802006', kitchen_orlando: '4336222000003731022', kitchen_westPalm: '', kitchen_lasVegas: '4336222000002148014', kitchen_fortMyers: '4336222000002480012', onlineProStudio: '4336222000001239006', onlineDesignStudio: '4336222000001239006', onlineKitchenDesign: '4336222000001239006' }, locations: { eastCoast: [ { key: 'miamiLakes', label: 'Miami Lakes, FL', address: '13939 NW 60th Ave, Miami Lakes, FL 33014', phone: '305-685-0005' }, { key: 'doral', label: 'Doral, FL', address: '1607 NW 82nd Ave, Doral, FL 33126', phone: '305-932-4203' }, { key: 'pompano', label: 'Pompano Beach, FL', address: '1718 West Atlantic Blvd, Pompano Beach, FL 33069', phone: '954-678-2203' }, { key: 'orlando', label: 'Orlando, FL', address: '10511 Satellite Blvd, Orlando, FL 32837', phone: '689-244-6222' }, { key: 'tampa', label: 'Tampa, FL', address: 'Tampa, FL' }, { key: 'westPalm', label: 'West Palm Beach, FL', address: 'West Palm Beach, FL' }, { key: 'fortMyers', label: 'Fort Myers, FL', address: 'Fort Myers, FL' } ], westCoast: [ { key: 'lasVegas', label: 'Las Vegas, NV', address: 'Las Vegas, NV' }, { key: 'vanNuys', label: 'Van Nuys, CA', address: 'Van Nuys, CA' } ] } }; // ── STATE ────────────────────────────────────────────────────────────────── let history = []; let isOpen = false; let isTyping = false; let sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2,9); let chatLogged = false; // Geo-IP cached state (US 2-letter code) and East/West region for White TFL pricing let userState = sessionStorage.getItem('lio_state') || null; let userRegion = sessionStorage.getItem('lio_region') || null; function deriveRegionFromState(state) { if (!state) return null; var WEST_STATES = ['CA','NV','AZ','NM','OR','WA','ID','UT','MT','WY','CO','AK','HI']; return WEST_STATES.indexOf(state.toUpperCase()) >= 0 ? 'West' : 'East'; } function detectUserRegion() { if (userState && userRegion) return; // already cached this session try { fetch('https://ipapi.co/json/', { cache: 'no-store' }) .then(function(r){ return r && r.ok ? r.json() : null; }) .then(function(d){ if (!d || d.error) return; if (d.country_code !== 'US') return; // FL/CA pricing only relevant in US var st = (d.region_code || '').toUpperCase(); if (!st) return; userState = st; userRegion = deriveRegionFromState(st); sessionStorage.setItem('lio_state', userState); if (userRegion) sessionStorage.setItem('lio_region', userRegion); }) .catch(function(){ /* silent fail; Lio will just ask */ }); } catch(e) { /* silent */ } } // Allow other code (e.g. lead form ZIP capture) to override geo-IP with a more // accurate signal. Pass a US 2-letter state code. function setUserStateOverride(state) { if (!state) return; var st = String(state).toUpperCase(); userState = st; userRegion = deriveRegionFromState(st); sessionStorage.setItem('lio_state', userState); if (userRegion) sessionStorage.setItem('lio_region', userRegion); } function getETNow() { return new Date(new Date().toLocaleString('en-US', { timeZone: 'America/New_York' })); } function isWeekend() { const day = getETNow().getDay(); return day === 0 || day === 6; } // Business hours: Mon-Fri 8am-5pm ET function isBusinessHours() { const et = getETNow(); const day = et.getDay(); const hour = et.getHours(); if (day === 0 || day === 6) return false; // weekend return hour >= 8 && hour < 17; // 8am-5pm } function nextOpenLabel() { const et = getETNow(); const day = et.getDay(); const hour = et.getHours(); if (day === 0) return 'tomorrow morning (Monday 8am ET)'; if (day === 6) return 'Monday morning (8am ET)'; if (day === 5 && hour >= 17) return 'Monday morning (8am ET)'; if (hour >= 17) return 'tomorrow morning (8am ET)'; if (hour < 8) return 'later today at 8am ET'; return 'shortly'; } function callbackConfirmMsg(phone) { if (isBusinessHours()) return "Our team will call " + phone + " shortly!"; return "Our team is offline right now \u2014 they'll call " + phone + " " + nextOpenLabel() + "."; } function callbackLeadMsg() { if (isBusinessHours()) return "Someone will call you back shortly \u2014 let me grab your details."; return "Our team is offline right now, but leave your details and they'll call you " + nextOpenLabel() + "."; } let chatOpenedAt = null; let chatOpenCount = 0; let userMessageCount = 0; let greeted = false; let leadCollected = false; let leadInfo = {}; let followUpEmailOffered = false; let conversationSummary = []; let selectedAppointmentType = ''; let selectedAppointmentUrl = ''; let sessionDirty = false; let chatMessagesEl = null; let lastWidgetType = 'none'; let buyingSignalScore = 0; let leadFormShownBySignal = false; let detectedLanguage = 'en'; let pageTrail = []; // Auto-detect East/West coast from browser timezone function getRegion() { try { var tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; var westKeywords = ['Los_Angeles', 'Phoenix', 'Denver', 'Boise', 'Anchorage', 'Juneau', 'Sitka', 'Yakutat', 'Nome', 'Adak', 'Honolulu', 'Seattle', 'Portland', 'Las_Vegas']; return westKeywords.some(function(k) { return tz.includes(k); }) ? 'west' : 'east'; } catch(e) { return 'east'; } } // ── CURRENT PAGE ────────────────────────────────────────────────────────── // The page the customer is viewing: sent to Lio with every message, and recorded as a route // (pageTrail: { at: number of chat messages when the page was opened, path, title, search }). function currentPage() { var title = String(document.title || '').replace(/\s*[|\u2013\u2014-]\s*Lioher\b.*$/i, '').replace(/\s+/g, ' ').trim().slice(0, 120); var search = ''; try { search = (new URLSearchParams(window.location.search).get('s') || '').trim().slice(0, 80); } catch (e) {} return { path: window.location.pathname || '/', title: title, search: search }; } function pageLabel(p) { return (p.title ? '"' + p.title + '" ' : '') + '(' + p.path + ')' + (p.search ? ' \u2014 site search: "' + p.search + '"' : ''); } function recordPageVisit() { try { pageTrail = JSON.parse(sessionStorage.getItem('lio_pageTrail') || '[]'); var p = currentPage(); var last = pageTrail[pageTrail.length - 1]; if (last && last.path === p.path && last.search === p.search) return; // The chat history of this tab is restored from sessionStorage when the chat opens. var stored = JSON.parse(sessionStorage.getItem('lio_history') || '[]'); pageTrail.push({ at: stored.length, path: p.path, title: p.title, search: p.search }); if (pageTrail.length > 30) pageTrail = pageTrail.slice(-30); sessionStorage.setItem('lio_pageTrail', JSON.stringify(pageTrail)); } catch (e) { pageTrail = []; } } function getChatMessages() { if (!chatMessagesEl) chatMessagesEl = document.getElementById('chat-messages'); return chatMessagesEl; } function saveSession() { if (!sessionDirty) return; try { sessionStorage.setItem('lio_history', JSON.stringify(history)); sessionStorage.setItem('lio_isOpen', isOpen); sessionStorage.setItem('lio_greeted', greeted); sessionStorage.setItem('lio_leadCollected', leadCollected); sessionStorage.setItem('lio_leadInfo', JSON.stringify(leadInfo)); sessionStorage.setItem('lio_followUpOffered', followUpEmailOffered); sessionStorage.setItem('lio_apptType', selectedAppointmentType); sessionStorage.setItem('lio_apptUrl', selectedAppointmentUrl); sessionStorage.setItem('lio_lastWidget', lastWidgetType); sessionStorage.setItem('lio_language', detectedLanguage); sessionDirty = false; } catch(e) {} } function restoreSession() { try { const h = sessionStorage.getItem('lio_history'); if (!h) return false; history = JSON.parse(h); // Mark restored messages so transcripts distinguish them from the current visit history.forEach(function(m){ if (m && m.role) m.restored = true; }); greeted = sessionStorage.getItem('lio_greeted') === 'true'; leadCollected = sessionStorage.getItem('lio_leadCollected') === 'true'; leadInfo = JSON.parse(sessionStorage.getItem('lio_leadInfo') || '{}'); followUpEmailOffered = sessionStorage.getItem('lio_followUpOffered') === 'true'; selectedAppointmentType = sessionStorage.getItem('lio_apptType') || ''; selectedAppointmentUrl = sessionStorage.getItem('lio_apptUrl') || ''; lastWidgetType = sessionStorage.getItem('lio_lastWidget') || 'none'; detectedLanguage = sessionStorage.getItem('lio_language') || 'en'; return history.length > 0; } catch(e) { return false; } } function replayHistory() { const container = getChatMessages(); container.innerHTML = ''; var lastAgentDiv = null; history.forEach(msg => { const div = document.createElement('div'); div.className = 'msg ' + (msg.role === 'user' ? 'user' : 'agent'); const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; // Use the same renderer so phones/URLs are linkified on replay too if (typeof renderBubbleContent === 'function') { bubble.innerHTML = renderBubbleContent(msg.content); } else { bubble.textContent = msg.content; } div.appendChild(bubble); container.appendChild(div); if (msg.role !== 'user') lastAgentDiv = div; }); // Restore last widget if it was the greeting menu if (lastWidgetType === 'greeting' && lastAgentDiv) { showMainMenu(lastAgentDiv, ['Products & Collections', 'Buy Samples', 'Showroom locations', 'Book an appointment', 'Talk to a Real Person']); } scrollBottom(); } window.addEventListener('beforeunload', function() { saveSession(); if (!chatLogged && chatOpenCount > 0) { logAllConversation(); chatLogged = true; } }); document.addEventListener('visibilitychange', function() { if (document.visibilityState === 'hidden' && !chatLogged && chatOpenCount > 0) { logAllConversation(); chatLogged = true; } }); setInterval(saveSession, 5000); function toggleChat() { isOpen = !isOpen; document.getElementById('lioher-chat').classList.toggle('open', isOpen); document.getElementById('lioher-launcher').classList.toggle('open', isOpen); if (isOpen) { chatLogged = false; chatOpenCount++; if (!chatOpenedAt) chatOpenedAt = new Date(); sessionStorage.setItem('lioher_chat_interacted', 'true'); var proactive = document.getElementById('lioher-proactive'); if (proactive) proactive.remove(); } if (isOpen && !greeted) { if (restoreSession()) { greeted = true; replayHistory(); addMessage('agent', 'Welcome back! Continuing where we left off.'); } else { greet(); greeted = true; } } if (isOpen) setTimeout(() => document.getElementById('lioher-input').focus(), 350); if (!isOpen && chatOpenCount > 0 && !chatLogged) { logAllConversation(); chatLogged = true; } } function getTime() { return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function scrollBottom() { const c = getChatMessages(); c.scrollTop = c.scrollHeight; } // Escape HTML then auto-link phone numbers (tel:) and URLs so they're tappable on mobile function renderBubbleContent(text) { var esc = String(text) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); // Phone numbers: match formats like (305) 685-0005, 305-685-0005, +1 305.685.0005 esc = esc.replace(/(\+?1[\s.\-]?)?\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}/g, function(m) { var digits = m.replace(/[^0-9]/g, ''); return '' + m + ''; }); // URLs: match http(s):// and bare domain like shop.lioher.com or lioher.com/designs/ esc = esc.replace(/\b((https?:\/\/[^\s<]+)|(([a-z0-9-]+\.)+(com|net|org|io|co)(\/[^\s<]*)?))/gi, function(m) { var href = m.startsWith('http') ? m : 'https://' + m; return '' + m + ''; }); return esc; } function addMessage(role, text) { const container = getChatMessages(); const div = document.createElement('div'); div.className = 'msg ' + role; const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; bubble.innerHTML = renderBubbleContent(text); div.appendChild(bubble); const time = document.createElement('div'); time.className = 'msg-time'; time.textContent = getTime(); div.appendChild(time); container.appendChild(div); scrollBottom(); sessionDirty = true; if (role === 'user') { history.push({ role: 'user', content: text }); userMessageCount++; chatLogged = false; } else if (role === 'agent' || role === 'assistant') { history.push({ role: 'assistant', content: text }); } return div; } function appendWidget(parentDiv, widget) { parentDiv.insertBefore(widget, parentDiv.querySelector('.msg-time')); scrollBottom(); // Capture suggested chips for transcript logging try { if (widget && widget.classList && widget.classList.contains('quick-replies')) { var chipEls = widget.querySelectorAll('.qr-chip'); var chips = []; for (var ci = 0; ci < chipEls.length; ci++) { var t = (chipEls[ci].textContent || '').trim(); if (t) chips.push(t); } if (chips.length > 0) { for (var hi = history.length - 1; hi >= 0; hi--) { if (history[hi].role === 'assistant') { history[hi].suggestedChips = (history[hi].suggestedChips || []).concat(chips); break; } } } } } catch(e) { /* ignore chip capture errors */ } } function showTyping() { const container = getChatMessages(); const div = document.createElement('div'); div.className = 'msg agent'; div.id = 'typing-indicator'; div.innerHTML = '
'; container.appendChild(div); scrollBottom(); } function removeTyping() { const el = document.getElementById('typing-indicator'); if (el) el.remove(); } function autoResize(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 100) + 'px'; } function handleKey(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } } // ── LEAD FORM FACTORY ────────────────────────────────────────────────── function createLeadForm(prefix, fields, onSubmitFn) { const form = document.createElement('div'); form.className = 'lead-form'; let html = '
Your details
'; const attr = v => String(v == null ? '' : v).replace(/&/g, '&').replace(/"/g, '"').replace(/ { const id = prefix + '-' + f.id; if (f.type === 'select') { html += ''; } else if (f.type === 'textarea') { html += ''; } else { html += ''; } }); form.innerHTML = html; const submitBtn = document.createElement('button'); submitBtn.className = 'lead-form-submit'; submitBtn.textContent = onSubmitFn.buttonText || 'Submit'; submitBtn.onclick = () => onSubmitFn(form, prefix); form.appendChild(submitBtn); // Abandonment recovery: show a subtle "Skip for now" link — no pressure const skipLink = document.createElement('div'); skipLink.style.cssText = 'text-align:center;margin-top:8px;font-size:10.5px;color:#9a928a;cursor:pointer;letter-spacing:0.02em;'; skipLink.textContent = detectedLanguage === 'es' ? 'Saltar por ahora' : 'Skip for now'; skipLink.onclick = function() { form.remove(); var skipMsg = detectedLanguage === 'es' ? 'No hay problema \u2014 estoy aqu\u00ed si necesita algo m\u00e1s. Tambi\u00e9n puede llamarnos al 305-685-0005 o visitar lioher.com cuando guste.' : 'No problem \u2014 I\'m here if you need anything else. You can also call us at 305-685-0005 or visit lioher.com whenever you\'re ready.'; addMessage('agent', skipMsg); }; form.appendChild(skipLink); // If the form sits untouched for 90s, offer a lighter alternative (no data collection) var abandonTimer = setTimeout(function() { if (form.parentNode && !leadCollected) { var reminderDiv = addMessage('agent', detectedLanguage === 'es' ? '\u00bfPrefiere que le enviemos la info por email sin llenar el formulario? O puede saltarlo por ahora.' : 'No pressure \u2014 want me to just email you the info instead? Or you can skip for now.'); } }, 90000); // Cancel the timer if form is removed or submitted var origRemove = form.remove.bind(form); form.remove = function() { clearTimeout(abandonTimer); origRemove(); }; return form; } // ── SHOWROOM FLOW ────────────────────────────────────────────────────────── function showShowroomFlow() { const replyDiv = addMessage('agent', "I'll grab your details so our team can follow up, and then you can explore our locations:"); 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(); const zip = document.getElementById(prefix + '-zip').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(); leadCollected = true; leadInfo = { first, last, company, phone, email }; sendLeadEmail(first, last, company, phone, email, 'Showroom Visit', zip, false); const confirmDiv = addMessage('agent', 'Thanks, ' + first + '! Our team will follow up with you. Here are our locations:'); const card = createMenuCard([{ label: 'View All Locations', url: CONFIG.urls.locations }]); appendWidget(confirmDiv, card); history.push({ role: 'user', content: 'My details: ' + first + ' ' + last + ', company: ' + (company||'N/A') + ', phone: ' + phone + ', email: ' + email }); }; onSubmit.buttonText = 'Submit'; const form = createLeadForm('sf', fields, onSubmit); // Add locations button const locBtn = document.createElement('a'); locBtn.href = CONFIG.urls.locations; locBtn.target = '_blank'; locBtn.className = 'lead-form-submit'; locBtn.style.cssText = 'display:block;text-align:center;text-decoration:none;margin-top:8px;background:#f0f4fb;color:#5b7ebf;border:1px solid #5b7ebf;font-weight:600;padding:9px;border-radius:8px;font-family:Montserrat,sans-serif;font-size:12.5px;letter-spacing:0.04em;transition:background 0.2s;'; locBtn.onmouseenter = () => { locBtn.style.background = '#5b7ebf'; locBtn.style.color = '#fff'; }; locBtn.onmouseleave = () => { locBtn.style.background = '#f0f4fb'; locBtn.style.color = '#5b7ebf'; }; locBtn.textContent = 'View All Locations'; form.appendChild(locBtn); appendWidget(replyDiv, form); } // ── FOLLOW-UP EMAIL FORM ──────────────────────────────────────────────── function showFollowUpForm(parentDiv) { const fields = [ { id: 'first', placeholder: 'First Name' }, { id: 'last', placeholder: 'Last Name' }, { id: 'email', placeholder: 'Email Address', type: 'email' } ]; const onSubmit = function(form, prefix) { const first = document.getElementById(prefix + '-first').value.trim(); const last = document.getElementById(prefix + '-last').value.trim(); const email = document.getElementById(prefix + '-email').value.trim(); if (!first || !email) { if (!first) document.getElementById(prefix + '-first').style.borderColor = '#e88'; if (!email) document.getElementById(prefix + '-email').style.borderColor = '#e88'; return; } form.remove(); sendFollowUpEmail(first, last, email, false); addMessage('agent', 'A summary will be sent to ' + email + ' at the end of the day \u2014 thanks ' + first + '!'); }; onSubmit.buttonText = 'Send Me the Summary'; const form = createLeadForm('fu', fields, onSubmit); appendWidget(parentDiv, form); } function buildTranscript() { // "[Page: ...]" lines show where the customer was when each part of the conversation happened. var lines = []; var t = 0; var flushPages = function(upTo) { while (t < pageTrail.length && pageTrail[t].at <= upTo) lines.push('[Page: ' + pageLabel(pageTrail[t++]) + ']'); }; history.forEach(function(m, i) { flushPages(i); const role = m.role === 'user' ? 'Customer' : 'Lio'; let line = role + ': ' + (m.restored ? '[restored] ' : '') + m.content; if (m.role === 'assistant' && m.suggestedChips && m.suggestedChips.length > 0) { line += '\n[Suggested: ' + m.suggestedChips.join(' | ') + ']'; } lines.push(line); }); flushPages(Infinity); return lines.join('\n'); } function sendLeadEmail(first, last, company, phone, email, appointmentType, zip, isCallback) { // Dedupe guard: identical send (same email + appointment type) within 5 minutes is a // double-click / resubmit — one row in the sheet is enough. var dedupeKey = String(email || '').toLowerCase() + '|' + String(appointmentType || ''); if (window.__lioLastLeadSend && window.__lioLastLeadSend.key === dedupeKey && (Date.now() - window.__lioLastLeadSend.at) < 300000) { console.log('LIOHER: duplicate lead send skipped:', dedupeKey); return; } window.__lioLastLeadSend = { key: dedupeKey, at: Date.now() }; leadCollected = true; leadInfo = Object.assign({}, leadInfo, { first, last, company, phone, email, zip }); // ZIP-derived state overrides geo-IP (more accurate for White TFL pricing). // ZIP first digit -> rough region. East-most digits 0-3, West digits 9. if (zip && /^\d{5}/.test(zip)) { var firstDigit = zip.charAt(0); var derivedRegion = (firstDigit === '9') ? 'West' : 'East'; // Cache the region (state stays whatever geo-IP found, if any) userRegion = derivedRegion; sessionStorage.setItem('lio_region', userRegion); } const transcript = buildTranscript(); try { fetch(CONFIG.webhooks.lead, { method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ first, last, company, phone, email, zip, city: leadInfo.city || '', appointmentType, isCallback, sendSMS: true, region: getRegion(), transcript: transcript, timestamp: new Date().toISOString() }) }).then(function(r) { if (!r.ok) console.error('Webhook failed:', r.status); }).catch(function(e) { console.error('Webhook error:', e); }); } catch(e) { console.log('Lead notification failed:', e); } } function logAllConversation() { if (userMessageCount === 0) return; console.log('LIOHER LOG: logAllConversation called, history length:', history.length, 'chatOpenCount:', chatOpenCount); const transcript = (history.length > 0 && buildTranscript) ? buildTranscript() : history.map(m => (m.role === 'user' ? 'Customer' : 'Lio') + ': ' + m.content).join('\n'); const durationSec = chatOpenedAt ? Math.round((new Date() - chatOpenedAt) / 1000) : 0; const payload = JSON.stringify({ sessionId: sessionId, transcript: transcript || '(Chat opened, no messages)', leadCaptured: leadCollected ? 'Yes' : 'No', name: leadCollected ? (leadInfo.first + ' ' + leadInfo.last) : '', phone: leadCollected ? leadInfo.phone : '', email: leadCollected ? leadInfo.email : '', appointmentType: leadCollected ? (selectedAppointmentType || 'Callback Request') : '', region: getRegion(), pageUrl: window.location.href, userMessages: userMessageCount, chatOpens: chatOpenCount, durationSeconds: durationSec, timestamp: new Date().toISOString() }); try { fetch(CONFIG.webhooks.allChats, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload, keepalive: true }).catch(function() { if (navigator.sendBeacon) { navigator.sendBeacon(CONFIG.webhooks.allChats, new Blob([payload], { type: 'application/json' })); } }); } catch(e) { console.log('All chats log failed:', e); } } function sendFollowUpEmail(first, last, email, hadCallback) { const summary = history.filter(m => m.role === 'user').map(m => m.content).join(' | '); try { fetch(CONFIG.webhooks.followup, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ first, last, email, hadCallback, callbackPhone: hadCallback ? leadInfo.phone : '', summary: summary, region: getRegion(), timestamp: new Date().toISOString() }) }).then(function(r) { if (!r.ok) console.error('Webhook failed:', r.status); }).catch(function(e) { console.error('Webhook error:', e); }); } catch(e) { console.log('Follow-up email failed:', e); } } // ── APPOINTMENT MENU ─────────────────────────────────────────────────────── function showAppointmentMenu() { const replyDiv = addMessage('agent', 'What type of appointment are you looking for?'); const card = document.createElement('div'); card.className = 'menu-card'; const options = [ { label: 'Quick Call (15-30 min)', action: 'quickCall' }, { label: 'In-Person Showroom Visit', action: 'showroom' }, { label: 'Online Kitchen Design', action: 'onlineKitchen' }, { label: 'FREE Closet Consultation', action: 'closet' } ]; options.forEach(opt => { const btn = document.createElement('button'); btn.className = 'menu-btn'; btn.textContent = opt.label; btn.onclick = () => { card.remove(); addMessage('user', opt.label); if (opt.action === 'quickCall') { showCoastPicker('quickCall'); } else if (opt.action === 'showroom') { showLocationPicker('showroom'); } else if (opt.action === 'onlineKitchen') { showKitchenOnlineMenu(); } else if (opt.action === 'closet') { showInlineBooking('closetConsult'); } }; card.appendChild(btn); }); appendWidget(replyDiv, card); } // ── KITCHEN DESIGN SUB-MENU ──────────────────────────────────────────────── function showKitchenDesignMenu() { const replyDiv = addMessage('agent', 'Are you a homeowner or a trade professional?'); const card = document.createElement('div'); card.className = 'menu-card'; [ { label: 'Homeowner', next: 'online' }, { label: 'Trade Professional', next: 'location' } ].forEach(opt => { const btn = document.createElement('button'); btn.className = 'menu-btn'; btn.textContent = opt.label; btn.onclick = () => { card.remove(); addMessage('user', opt.label); if (opt.next === 'online') { showKitchenOnlineMenu(); } else { leadInfo.customerType = 'Professional'; showLocationPicker('kitchen'); } }; card.appendChild(btn); }); appendWidget(replyDiv, card); } // ── SHOW BOOKING CARD (now uses inline booking) ──────────────────────────── function showBookingCard(type, url) { selectedAppointmentType = type; selectedAppointmentUrl = url; // Map old URLs to new service keys if (url === CONFIG.appointments.quickCall) { showCoastPicker('quickCall'); } else if (url === CONFIG.appointments.discoverySelection) { showLocationPicker('showroom'); } else if (url === CONFIG.appointments.homeowner) { showKitchenOnlineMenu(); } else { showInlineBooking('quickCallEast'); } } // ── COAST PICKER (for Quick Calls) ────────────────────────────────────── function showCoastPicker(type) { const replyDiv = addMessage('agent', 'Which coast are you on?'); const card = document.createElement('div'); card.className = 'menu-card'; [ { label: 'East Coast', key: type === 'quickCall' ? 'quickCallEast' : null }, { label: 'West Coast', key: type === 'quickCall' ? 'quickCallWest' : null } ].forEach(opt => { const btn = document.createElement('button'); btn.className = 'menu-btn'; btn.textContent = opt.label; btn.onclick = () => { card.remove(); addMessage('user', opt.label); showInlineBooking(opt.key); }; card.appendChild(btn); }); appendWidget(replyDiv, card); } // ── LOCATION PICKER (for Showroom & Kitchen Design) ───────────────────── function showLocationPicker(type) { const replyDiv = addMessage('agent', 'Which showroom would you like to visit?'); const card = document.createElement('div'); card.className = 'menu-card'; const allLocations = [...CONFIG.locations.eastCoast, ...CONFIG.locations.westCoast]; allLocations.forEach(loc => { const btn = document.createElement('button'); btn.className = 'menu-btn'; btn.textContent = loc.label; btn.onclick = () => { card.remove(); addMessage('user', loc.label); var serviceKey = type + '_' + loc.key; showInlineBooking(serviceKey); }; card.appendChild(btn); }); appendWidget(replyDiv, card); } // ── ONLINE KITCHEN DESIGN MENU ────────────────────────────────────────── // Online Design is a PAID $95 session — payment must go through Zoho Bookings, // so we redirect to the external Zoho link instead of using the inline booking flow. function showKitchenOnlineMenu() { const replyDiv = addMessage('agent', 'Our online kitchen design sessions are $95 — payment is handled securely through Zoho Bookings. Are you a homeowner or a trade professional?'); const card = document.createElement('div'); card.className = 'menu-card'; [ { label: 'Homeowner — Lioher Design Studio ($95)', url: 'https://usa-appointments.zohobookings.com/#/4336222000002845072' }, { label: 'Trade Professional — Lioher Pro Studio ($95)', url: 'https://usa-appointments.zohobookings.com/#/4336222000002828054' } ].forEach(opt => { const btn = document.createElement('button'); btn.className = 'menu-btn'; btn.textContent = opt.label; btn.onclick = () => { card.remove(); addMessage('user', opt.label); addMessage('agent', "Opening the booking page in a new tab — you'll be able to pick a time and complete the $95 payment there. Is there anything else I can help you with in the meantime?"); window.open(opt.url, '_blank', 'noopener,noreferrer'); }; card.appendChild(btn); }); appendWidget(replyDiv, card); } // ── BOOKING HELPERS ─────────────────────────────────────────────────────── // Showroom visits and in-person kitchen design need extra Zoho fields (homeowner/professional, city, details). function bookingNeedsProfile(serviceKey) { return /^(showroom|kitchen)_/.test(serviceKey || ''); } function bookingLocation(serviceKey) { var key = String(serviceKey || '').split('_')[1]; return [].concat(CONFIG.locations.eastCoast, CONFIG.locations.westCoast).find(function(l) { return l.key === key; }) || null; } function bookingLabel(serviceKey) { var loc = bookingLocation(serviceKey); if (/^showroom_/.test(serviceKey)) return 'Showroom Visit' + (loc ? ' \u2014 ' + loc.label : ''); if (/^kitchen_/.test(serviceKey)) return 'In-Person Kitchen Design' + (loc ? ' \u2014 ' + loc.label : ''); if (/^quickCall/.test(serviceKey)) return 'Quick Call'; if (serviceKey === 'closetConsult') return 'Closet Consultation'; return 'Appointment'; } // A booking that cannot be confirmed (or no shown time works) goes to the team as a callback lead, // so the request is never lost. function sendBookingRequest(serviceKey, whenText, info) { var label = bookingLabel(serviceKey); selectedAppointmentType = label; sendLeadEmail(info.first || '', info.last || '', info.company || '', info.phone || '', info.email || '', 'Booking request (not confirmed) \u2014 ' + label + (whenText ? ' \u2014 ' + whenText : ''), info.zip || '', true); var loc = bookingLocation(serviceKey); var phoneLine = loc && loc.phone ? ' If you prefer, you can also call our ' + loc.label.split(',')[0] + ' center at ' + loc.phone + '.' : ' If you prefer, you can also call or WhatsApp our Inside Sales team at 786-218-0063.'; addMessage('agent', (whenText ? "I couldn't confirm " + whenText + " automatically, so I've sent your request" : "I've sent your request") + ' to our team \u2014 they will call you to set it up.' + phoneLine); } // No shown time works (or the calendar is unavailable): collect contact details and hand it to the team. function requestBookingFromTeam(serviceKey) { addMessage('user', 'Please have the team schedule it for me'); if (leadInfo.first && leadInfo.phone && leadInfo.email) { sendBookingRequest(serviceKey, '', leadInfo); return; } var reqDiv = addMessage('agent', 'Sure \u2014 leave your details and the team will call you to find a time.'); var reqSubmit = function(form, prefix) { // Read every value before the form is removed from the page. var v = {}; ['first', 'last', 'phone', 'email'].forEach(function(id) { v[id] = document.getElementById(prefix + '-' + id).value.trim(); }); var valid = true; ['first', 'phone', 'email'].forEach(function(id) { var bad = !v[id] || (id === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v[id])); document.getElementById(prefix + '-' + id).classList.toggle('error', bad); if (bad) valid = false; }); if (!valid) return; form.remove(); leadCollected = true; leadInfo = Object.assign({}, leadInfo, { first: v.first, last: v.last, phone: v.phone, email: v.email }); sendBookingRequest(serviceKey, '', leadInfo); }; reqSubmit.buttonText = 'Request a Call'; appendWidget(reqDiv, createLeadForm('br', [ { id: 'first', placeholder: 'First Name' }, { id: 'last', placeholder: 'Last Name' }, { id: 'phone', placeholder: 'Phone Number', type: 'tel' }, { id: 'email', placeholder: 'Email Address', type: 'email' } ], reqSubmit)); } // ── INLINE BOOKING ──────────────────────────────────────────────────────── function showInlineBooking(serviceKey) { var serviceId = CONFIG.serviceIds[serviceKey]; var staffId = CONFIG.staffIds[serviceKey] || ''; if (!serviceId) { addMessage('agent', "Sorry, I couldn't find that service. Let me help you another way."); showAppointmentMenu(); return; } var serviceName = serviceKey || 'Appointment'; var msgDiv = addMessage('agent', 'Let me find available times for you.'); var card = document.createElement('div'); card.className = 'booking-card'; // Generate next 5 business days var dates = []; var now = new Date(); var d = new Date(now); d.setDate(d.getDate() + 1); while (dates.length < 5) { var day = d.getDay(); if (day !== 0 && day !== 6) { dates.push(new Date(d)); } d.setDate(d.getDate() + 1); } var dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; var monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; function formatDateForApi(dt) { return dt.getDate().toString().padStart(2,'0') + '-' + monthNames[dt.getMonth()] + '-' + dt.getFullYear(); } function formatDateDisplay(dt) { return monthNames[dt.getMonth()] + ' ' + dt.getDate(); } function formatTime12h(timeStr) { var parts = timeStr.split(':'); var h = parseInt(parts[0]); var m = parts[1]; var ampm = h >= 12 ? 'PM' : 'AM'; if (h > 12) h -= 12; if (h === 0) h = 12; return h + ':' + m + ' ' + ampm; } card.innerHTML = '
\u{1f4c5} Select a date
' + dates.map(function(dt) { return ''; }).join('') + '
'; var altBtn = document.createElement('button'); altBtn.className = 'booking-alt'; altBtn.textContent = 'None of these work? Ask the team to schedule it'; altBtn.onclick = function() { card.remove(); requestBookingFromTeam(serviceKey); }; card.appendChild(altBtn); appendWidget(msgDiv, card); // Date button click handlers card.querySelectorAll('.booking-date-btn').forEach(function(btn) { btn.addEventListener('click', function() { card.querySelectorAll('.booking-date-btn').forEach(function(b) { b.classList.remove('selected'); }); this.classList.add('selected'); var selectedDate = this.dataset.date; var timesContainer = card.querySelector('#booking-times-container'); timesContainer.innerHTML = '
Loading available times...
'; fetch(CONFIG.bookingProxy + '?service_id=' + serviceId + '&staff_id=' + staffId + '&date=' + selectedDate) .then(function(res) { if (!res.ok) throw new Error('HTTP ' + res.status); return res.json(); }) .then(function(data) { if (data.error) { timesContainer.innerHTML = '
I couldn\'t load the times right now. Try again in a minute, or use the link below and the team will call you.
'; return; } var slots = Array.isArray(data.slots) ? data.slots : []; if (slots.length === 0) { timesContainer.innerHTML = '
No available times on this date. Try another day.
'; return; } var displaySlots = slots.length > 12 ? slots.filter(function(s, i) { return i % 2 === 0; }).slice(0, 12) : slots; timesContainer.innerHTML = '
\u23f0 Pick a time (ET)
' + displaySlots.map(function(slot) { return ''; }).join('') + '
'; timesContainer.querySelectorAll('.booking-time-btn').forEach(function(timeBtn) { timeBtn.addEventListener('click', function() { timesContainer.querySelectorAll('.booking-time-btn').forEach(function(b) { b.classList.remove('selected'); }); this.classList.add('selected'); var selectedTime = this.dataset.time; var fullDateTime = selectedDate + ' ' + selectedTime + ':00'; card.remove(); var matchDate = dates.find(function(dt) { return formatDateForApi(dt) === selectedDate; }); addMessage('user', formatDateDisplay(matchDate || new Date()) + ' at ' + formatTime12h(selectedTime)); var whenText = formatDateDisplay(matchDate || new Date()) + ' at ' + formatTime12h(selectedTime); var needsProfile = bookingNeedsProfile(serviceKey); var haveContact = leadCollected && leadInfo.first && leadInfo.phone && leadInfo.email; if (haveContact && (!needsProfile || (leadInfo.city && leadInfo.customerType))) { confirmAndBook(serviceKey, serviceId, staffId, fullDateTime, selectedTime, whenText, leadInfo); } else { var formDiv = addMessage('agent', 'Great choice! Just need your details to confirm the booking.'); var bkFields = [ { id: 'first', placeholder: 'First Name', value: leadInfo.first }, { id: 'last', placeholder: 'Last Name', value: leadInfo.last }, { id: 'phone', placeholder: 'Phone Number', type: 'tel', value: leadInfo.phone }, { id: 'email', placeholder: 'Email Address', type: 'email', value: leadInfo.email } ]; if (needsProfile) { bkFields.push( { id: 'type', type: 'select', placeholder: 'Homeowner or professional?', options: ['Homeowner', 'Professional'], value: leadInfo.customerType }, { id: 'city', placeholder: 'Your City', value: leadInfo.city }, { id: 'company', placeholder: 'Company (if you are a professional)', value: leadInfo.company }, { id: 'details', type: 'textarea', placeholder: 'Tell us about your project (optional)', value: leadInfo.details } ); } var bkSubmit = function(form, prefix) { // Read every value before the form is removed from the page. var v = {}; ['first', 'last', 'phone', 'email', 'type', 'city', 'company', 'details'].forEach(function(id) { var el = document.getElementById(prefix + '-' + id); v[id] = el ? el.value.trim() : ''; }); var valid = true; ['first', 'phone', 'email'].concat(needsProfile ? ['type', 'city'] : []).forEach(function(id) { var bad = !v[id] || (id === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v[id])); document.getElementById(prefix + '-' + id).classList.toggle('error', bad); if (bad) valid = false; }); if (!valid) return; form.remove(); leadCollected = true; leadInfo = Object.assign({}, leadInfo, { first: v.first, last: v.last, phone: v.phone, email: v.email }); if (needsProfile) { leadInfo.customerType = v.type; leadInfo.city = v.city; leadInfo.company = v.company; leadInfo.details = v.details; } confirmAndBook(serviceKey, serviceId, staffId, fullDateTime, selectedTime, whenText, leadInfo); }; bkSubmit.buttonText = 'Confirm Booking'; var bkForm = createLeadForm('bk', bkFields, bkSubmit); appendWidget(formDiv, bkForm); } }); }); }) .catch(function(err) { timesContainer.innerHTML = '
Unable to load times. Please try again.
'; console.error('Booking slots error:', err); }); }); }); } var _slotTakenCount = 0; function confirmAndBook(serviceKey, serviceId, staffId, fullDateTime, selectedTime, whenText, info) { var bookingDiv = addMessage('agent', 'Booking your appointment...'); var label = bookingLabel(serviceKey); var name = ((info.first || '') + ' ' + (info.last || '')).trim(); var esc = function(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>'); }; fetch(CONFIG.bookingCreate, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service_id: serviceId, staff_id: staffId, from_time: fullDateTime, name: name, email: info.email, phone: info.phone, company: info.company || '', zip: info.zip || '', city: info.city || '', customer_type: info.customerType || '', details: info.details || '', notes: 'Booked via Lioher CS chatbot \u2014 ' + label }) }) .then(function(res) { return res.json(); }) .then(function(data) { if (data.success) { selectedAppointmentType = label; var confirmCard = document.createElement('div'); confirmCard.className = 'booking-confirm'; confirmCard.innerHTML = '
\u2705 Appointment Confirmed!
' + '
Appointment: ' + esc(label) + '
' + '
When: ' + esc(whenText) + ' ET
' + '
Name: ' + esc(name) + '
' + '
Email: ' + esc(info.email) + '
' + '
A confirmation will be sent to your email.
'; 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 = ''; 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(); })();