1/* robot-ceilingo.js (Ceilingo Prospect 1.37, contrat § 5). Syntaxe de 2019 : Safari 12. 2 Le robot de discussion des sites Ceilingo : une pastille, une fenêtre, aucune dépendance, 3 aucun cookie, aucune ressource extérieure. Tout ce qui vient du serveur est rendu en texte 4 par le DOM, jamais par innerHTML. */ 5(function () { 6 'use strict'; 7 8 // Chargé deux fois (module et collage manuel) : le second exemplaire se tait. 9 if (window.CeilingoRobot) return; 10 11 // Seules adresses où le fichier parle : un code absent de cette table ne fait rien. 12 var INSTANCES = { m: 'https://prospect.ceilingo.fr', p: 'https://partenaires.ceilingo.fr' }; 13 // Domaines vers lesquels un lien rendu dans une réponse peut pointer. Tout autre lien reste du texte. 14 var DOMAINES_LIENS = ['ceilingo.fr', 'ceilingo-isolation.com', 'magicspeaker.fr', 'prospect.ceilingo.fr', 'partenaires.ceilingo.fr']; 15 16 var CLE = 'cp_robot'; 17 var DUREE_GARDE = 2592e6; // 30 jours : retrouver le fil au retour 18 var BLEU = '#195eab', ORANGE = '#f26223'; // relevés sur le logo de ceilingo.fr le 24/09/2026 19 20 var cfg = window.CeilingoRobotConfig || {}; 21 // Textes de la bulle dans les sept langues des sites (fr, en, de, it, es, pt, nl), choisis d'après la 22 // langue de la page ; l'anglais est le repli pour toute autre langue (constat d'Olivier du 26/09/2026 : 23 // la bulle restait en français sur /en/). L'accueil et les suggestions viennent du serveur, dans la même langue. 24 var TEXTES = { 25 fr: { pastille_aria: 'Poser une question à Ceilingo', pastille: 'Une question ?', dialogue: 'Discussion avec Ceilingo', repond: 'Répond tout de suite', wa_aria: 'Discuter avec Ceilingo sur WhatsApp', wa_bulle: 'Discuter sur WhatsApp', fermer: 'Fermer', rappel: 'Laisser mes coordonnées', placeholder: 'Ãcrivez votre questionâ¦', message_aria: 'Votre message', envoyer: 'Envoyer', dicter: 'Dicter ma question', arreter_dictee: 'Arrêter la dictée', mentions: 'Vos messages sont traités par Ceilingo et son prestataire dâIA pour vous répondre. Aucun cookie. ', dictee_mention: 'La dictée, si vous lâutilisez, passe par la reconnaissance vocale de votre navigateur. ', savoir_plus: 'En savoir plus', nouvelle: 'Nouvelle discussion', trop_vite: 'Un instant, vous allez trop vite pour moi.', injoignable: 'Je nâarrive pas à joindre Ceilingo pour le moment. Réessayez dans un instant.', conseiller: 'Un conseiller', vous_repond: ' vous répond', voir: 'Voir la fiche', suggestions: 'Suggestions', expiree: 'Notre discussion a expiré, je repars de zéro.', rappelle: 'On vous rappelle ?', nom: 'Votre nom', courriel: 'Votre courriel', tel: 'Votre téléphone (facultatif)', souhait: 'Ce que vous souhaitez (facultatif)', envoyer_coord: 'Envoyer mes coordonnées', plus_tard: 'Plus tard', pas_de_souci: 'Pas de souci. Vous pourrez laisser vos coordonnées à tout moment avec le bouton ci-dessous, ou continuer à me poser vos questions.', nom_svp: 'Votre nom, sâil vous plaît.', courriel_svp: 'Un courriel valide, sâil vous plaît.', merci: 'Merci, nous revenons vers vous très vite.', deja: 'Vos coordonnées nous sont déjà parvenues.', envoi_impossible: 'Envoi impossible pour le moment, réessayez dans un instant.' }, 26 en: { pastille_aria: 'Ask Ceilingo a question', pastille: 'A question?', dialogue: 'Chat with Ceilingo', repond: 'Replies right away', wa_aria: 'Chat with Ceilingo on WhatsApp', wa_bulle: 'Chat on WhatsApp', fermer: 'Close', rappel: 'Leave my contact details', placeholder: 'Type your questionâ¦', message_aria: 'Your message', envoyer: 'Send', dicter: 'Dictate my question', arreter_dictee: 'Stop dictation', mentions: 'Your messages are processed by Ceilingo and its AI provider to answer you. No cookies. ', dictee_mention: 'Dictation, if you use it, relies on your browserâs speech recognition. ', savoir_plus: 'Learn more', nouvelle: 'New chat', trop_vite: 'One moment, you are going a little fast for me.', injoignable: 'I cannot reach Ceilingo right now. Please try again in a moment.', conseiller: 'An adviser', vous_repond: ' is replying', voir: 'View product', suggestions: 'Suggestions', expiree: 'Our chat has expired, I am starting over.', rappelle: 'Shall we call you back?', nom: 'Your name', courriel: 'Your email', tel: 'Your phone (optional)', souhait: 'What you would like (optional)', envoyer_coord: 'Send my details', plus_tard: 'Later', pas_de_souci: 'No problem. You can leave your details at any time with the button below, or keep asking me questions.', nom_svp: 'Your name, please.', courriel_svp: 'A valid email, please.', merci: 'Thank you, we will get back to you very soon.', deja: 'We already have your details.', envoi_impossible: 'Cannot send right now, please try again in a moment.' }, 27 de: { pastille_aria: 'Ceilingo eine Frage stellen', pastille: 'Eine Frage?', dialogue: 'Gespräch mit Ceilingo', repond: 'Antwortet sofort', wa_aria: 'Mit Ceilingo über WhatsApp chatten', wa_bulle: 'Ãber WhatsApp chatten', fermer: 'SchlieÃen', rappel: 'Meine Kontaktdaten hinterlassen', placeholder: 'Schreiben Sie Ihre Frageâ¦', message_aria: 'Ihre Nachricht', envoyer: 'Senden', dicter: 'Meine Frage diktieren', arreter_dictee: 'Diktat beenden', mentions: 'Ihre Nachrichten werden von Ceilingo und seinem KI-Dienstleister verarbeitet, um Ihnen zu antworten. Keine Cookies. ', dictee_mention: 'Das Diktat, falls Sie es nutzen, läuft über die Spracherkennung Ihres Browsers. ', savoir_plus: 'Mehr erfahren', nouvelle: 'Neues Gespräch', trop_vite: 'Einen Moment, Sie sind etwas zu schnell für mich.', injoignable: 'Ich erreiche Ceilingo im Moment nicht. Bitte versuchen Sie es gleich noch einmal.', conseiller: 'Ein Berater', vous_repond: ' antwortet Ihnen', voir: 'Produkt ansehen', suggestions: 'Vorschläge', expiree: 'Unser Gespräch ist abgelaufen, ich beginne von vorn.', rappelle: 'Sollen wir Sie zurückrufen?', nom: 'Ihr Name', courriel: 'Ihre E-Mail-Adresse', tel: 'Ihre Telefonnummer (optional)', souhait: 'Was Sie möchten (optional)', envoyer_coord: 'Meine Kontaktdaten senden', plus_tard: 'Später', pas_de_souci: 'Kein Problem. Sie können Ihre Kontaktdaten jederzeit über die Schaltfläche unten hinterlassen oder mir weitere Fragen stellen.', nom_svp: 'Bitte geben Sie Ihren Namen an.', courriel_svp: 'Bitte geben Sie eine gültige E-Mail-Adresse an.', merci: 'Vielen Dank, wir melden uns sehr bald bei Ihnen.', deja: 'Ihre Kontaktdaten liegen uns bereits vor.', envoi_impossible: 'Senden derzeit nicht möglich, bitte versuchen Sie es gleich noch einmal.' }, 28 it: { pastille_aria: 'Fare una domanda a Ceilingo', pastille: 'Una domanda?', dialogue: 'Conversazione con Ceilingo', repond: 'Risponde subito', wa_aria: 'Parlare con Ceilingo su WhatsApp', wa_bulle: 'Parlare su WhatsApp', fermer: 'Chiudi', rappel: 'Lasciare i miei recapiti', placeholder: 'Scriva la Sua domandaâ¦', message_aria: 'Il Suo messaggio', envoyer: 'Invia', dicter: 'Dettare la mia domanda', arreter_dictee: 'Interrompere la dettatura', mentions: 'I Suoi messaggi sono trattati da Ceilingo e dal suo fornitore di IA per risponderLe. Nessun cookie. ', dictee_mention: 'La dettatura, se la utilizza, passa dal riconoscimento vocale del Suo browser. ', savoir_plus: 'Maggiori informazioni', nouvelle: 'Nuova conversazione', trop_vite: 'Un attimo, va un poâ troppo veloce per me.', injoignable: 'Non riesco a contattare Ceilingo in questo momento. Riprovi tra poco.', conseiller: 'Un consulente', vous_repond: ' Le risponde', voir: 'Vedere il prodotto', suggestions: 'Suggerimenti', expiree: 'La nostra conversazione è scaduta, ricomincio da capo.', rappelle: 'Desidera essere richiamato?', nom: 'Il Suo nome', courriel: 'La Sua email', tel: 'Il Suo telefono (facoltativo)', souhait: 'Cosa desidera (facoltativo)', envoyer_coord: 'Inviare i miei recapiti', plus_tard: 'Più tardi', pas_de_souci: 'Nessun problema. Può lasciare i Suoi recapiti in qualsiasi momento con il pulsante qui sotto, oppure continuare a farmi domande.', nom_svp: 'Il Suo nome, per favore.', courriel_svp: 'Unâemail valida, per favore.', merci: 'Grazie, La ricontattiamo molto presto.', deja: 'Abbiamo già ricevuto i Suoi recapiti.', envoi_impossible: 'Invio impossibile per il momento, riprov
28i tra poco.' }, 29 es: { pastille_aria: 'Hacer una pregunta a Ceilingo', pastille: '¿Una pregunta?', dialogue: 'Conversación con Ceilingo', repond: 'Responde al instante', wa_aria: 'Chatear con Ceilingo por WhatsApp', wa_bulle: 'Chatear por WhatsApp', fermer: 'Cerrar', rappel: 'Dejar mis datos de contacto', placeholder: 'Escriba su preguntaâ¦', message_aria: 'Su mensaje', envoyer: 'Enviar', dicter: 'Dictar mi pregunta', arreter_dictee: 'Detener el dictado', mentions: 'Sus mensajes son tratados por Ceilingo y su proveedor de IA para responderle. Sin cookies. ', dictee_mention: 'El dictado, si lo utiliza, pasa por el reconocimiento de voz de su navegador. ', savoir_plus: 'Más información', nouvelle: 'Nueva conversación', trop_vite: 'Un momento, va un poco rápido para mÃ.', injoignable: 'No consigo contactar con Ceilingo en este momento. Vuelva a intentarlo en un instante.', conseiller: 'Un asesor', vous_repond: ' le responde', voir: 'Ver el producto', suggestions: 'Sugerencias', expiree: 'Nuestra conversación ha caducado, empiezo de cero.', rappelle: '¿Le llamamos?', nom: 'Su nombre', courriel: 'Su correo electrónico', tel: 'Su teléfono (opcional)', souhait: 'Lo que desea (opcional)', envoyer_coord: 'Enviar mis datos', plus_tard: 'Más tarde', pas_de_souci: 'Sin problema. Puede dejar sus datos en cualquier momento con el botón de abajo, o seguir haciéndome preguntas.', nom_svp: 'Su nombre, por favor.', courriel_svp: 'Un correo electrónico válido, por favor.', merci: 'Gracias, nos pondremos en contacto con usted muy pronto.', deja: 'Ya hemos recibido sus datos.', envoi_impossible: 'No se puede enviar por el momento, vuelva a intentarlo en un instante.' }, 30 pt: { pastille_aria: 'Fazer uma pergunta à Ceilingo', pastille: 'Uma pergunta?', dialogue: 'Conversa com a Ceilingo', repond: 'Responde de imediato', wa_aria: 'Falar com a Ceilingo no WhatsApp', wa_bulle: 'Falar no WhatsApp', fermer: 'Fechar', rappel: 'Deixar os meus contactos', placeholder: 'Escreva a sua perguntaâ¦', message_aria: 'A sua mensagem', envoyer: 'Enviar', dicter: 'Ditar a minha pergunta', arreter_dictee: 'Parar o ditado', mentions: 'As suas mensagens são tratadas pela Ceilingo e pelo seu fornecedor de IA para lhe responder. Sem cookies. ', dictee_mention: 'O ditado, se o utilizar, passa pelo reconhecimento de voz do seu navegador. ', savoir_plus: 'Saber mais', nouvelle: 'Nova conversa', trop_vite: 'Um momento, está a ir um pouco depressa para mim.', injoignable: 'Não consigo contactar a Ceilingo neste momento. Tente novamente daqui a pouco.', conseiller: 'Um consultor', vous_repond: ' responde-lhe', voir: 'Ver o produto', suggestions: 'Sugestões', expiree: 'A nossa conversa expirou, recomeço do zero.', rappelle: 'Quer que lhe liguemos?', nom: 'O seu nome', courriel: 'O seu e-mail', tel: 'O seu telefone (opcional)', souhait: 'O que pretende (opcional)', envoyer_coord: 'Enviar os meus contactos', plus_tard: 'Mais tarde', pas_de_souci: 'Sem problema. Pode deixar os seus contactos a qualquer momento com o botão abaixo, ou continuar a fazer-me perguntas.', nom_svp: 'O seu nome, por favor.', courriel_svp: 'Um e-mail válido, por favor.', merci: 'Obrigado, entraremos em contacto consigo muito em breve.', deja: 'Já recebemos os seus contactos.', envoi_impossible: 'Envio impossÃvel de momento, tente novamente daqui a pouco.' }, 31 nl: { pastille_aria: 'Stel Ceilingo een vraag', pastille: 'Een vraag?', dialogue: 'Gesprek met Ceilingo', repond: 'Antwoordt meteen', wa_aria: 'Chat met Ceilingo via WhatsApp', wa_bulle: 'Chat via WhatsApp', fermer: 'Sluiten', rappel: 'Mijn gegevens achterlaten', placeholder: 'Typ uw vraagâ¦', message_aria: 'Uw bericht', envoyer: 'Verzenden', dicter: 'Mijn vraag dicteren', arreter_dictee: 'Dicteren stoppen', mentions: 'Uw berichten worden verwerkt door Ceilingo en zijn AI-leverancier om u te antwoorden. Geen cookies. ', dictee_mention: 'Dicteren, als u dat gebruikt, verloopt via de spraakherkenning van uw browser. ', savoir_plus: 'Meer weten', nouvelle: 'Nieuw gesprek', trop_vite: 'Een ogenblik, u gaat iets te snel voor mij.', injoignable: 'Ik kan Ceilingo op dit moment niet bereiken. Probeer het zo meteen opnieuw.', conseiller: 'Een adviseur', vous_repond: ' antwoordt u', voir: 'Product bekijken', suggestions: 'Suggesties', expiree: 'Ons gesprek is verlopen, ik begin opnieuw.', rappelle: 'Zullen we u terugbellen?', nom: 'Uw naam', courriel: 'Uw e-mailadres', tel: 'Uw telefoonnummer (optioneel)', souhait: 'Wat u wenst (optioneel)', envoyer_coord: 'Mijn gegevens verzenden', plus_tard: 'Later', pas_de_souci: 'Geen probleem. U kunt uw gegevens op elk moment achterlaten met de knop hieronder, of mij verder vragen stellen.', nom_svp: 'Uw naam, alstublieft.', courriel_svp: 'Een geldig e-mailadres, alstublieft.', merci: 'Dank u, wij nemen zeer binnenkort contact met u op.', deja: 'Wij hebben uw gegevens al ontvangen.', envoi_impossible: 'Verzenden is momenteel niet mogelijk, probeer het zo meteen opnieuw.' } 32 }; 33 var LANG = (function () { var l = (document.documentElement.lang || navigator.language || 'fr').slice(0, 2).toLowerCase(); return TEXTES[l] ? l : 'en'; })(); 34 var T = TEXTES[LANG]; 35 var scriptCourant = document.currentScript; 36 var code = cfg.code || (scriptCourant && scriptCourant.getAttribute('data-cp')) || ''; 37 if (!Object.prototype.hasOwnProperty.call(INSTANCES, code)) return; 38 var BASE = INSTANCES[code] + '/f/robot'; 39 var reduit = false; 40 try { reduit = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {} 41 42 // ââ Ãtat âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 43 var jeton = null, ouvert = false, mode = 'robot', nomRobot = 'Ceilingo', dernierId = 0; 44 var enAttente = false, minuteriePoll = null, chargeUneFois = false, whatsapp = ''; 45 var suggestions = [], premierMessageEnvoye = false, politique = cfg.politique || ''; 46 47 function lireGarde() { try { var g = JSON.parse(window.localStorage.getItem(CLE)); return g && g.expire > Date.now() ? g : null; } catch (e) { return null; } } 48 function ecrireGarde() { try { window.localStorage.setItem(CLE, JSON.stringify({ jeton: jeton, langue: LANG, expire: Date.now() + DUREE_GARDE })); } catch (e) {} } 49 function effacerGarde() { try { window.localStorage.removeItem(CLE); } catch (e) {} } 50 // Le jeton du suivi 1.36 (`cp_suivi`) n'est transmis que s'il existe, donc que si le visiteur a accepté. 51 function jetonVisite() { try { var g = JSON.parse(window.localStorage.getItem('cp_suivi')); return g && g.jeton && g.expire > Date.now() ? g.jeton : ''; } catch (e) { return ''; } } 52 function couper(t, n) { t = String(t || ''); return t.length > n ? t.slice(0, n) : t; } 53 function adressePage() { return couper(location.protocol + '//' + location.host + location.pathname, 500); } 54 function langueNavigateur() { return couper((document.documentElement.lang || navigator.language || 'fr').slice(0, 2).toLowerCase(), 2); } 55 56 // ââ Réseau âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 57 // `text/plain` : pas de pré-vol CORS pour un POST simple ; jamais de cookie (`credentials: 'omit'`). 58 function appeler(methode, chemin, corps, suite) { 59 if (!window.fetch) { suite(0, null); return; } 60 var options = { method: methode, mode: 'cors', credentials: 'omit', headers: {} }; 61 if (corps) { options.body = JSON.stringify(corps); options.headers['Content-Type'] = 'text/plain'; } 62 window.fetch(BASE + chemin, options).then(function (r) { 63 if (r.status === 204) { suite(204, null); return; } 64 return r.text().then(function (t) { var d = null; try { d = JSON.parse(t); } catch (e) {} suite(r.status, d); }); 65 })['catch'](function () { suite(0, null); }); 66 } 67 68 // ââ Feuille de style âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 69 var CSS = [ 70 '.cpr-x{--cpr-b:' + BLEU + ';--cpr-o:' + ORANGE + ';--cpr-t:#23303f;--cpr-m:#6b7280;--cpr-ol:#fff1ea;--cpr-bl:#eaf1fa;font-family:inherit;line-height:1.45;color:var(--cpr-t);-webkit-font-smoothing:antialiased;box-sizing:border-box}', 71 '.cpr-x *,.cpr-x *:before,.cpr-x *:after{box-sizing:inherit}', 72 /* La pastille : blanche, ombre longue, soleil à gauche. Jamais une bulle ronde. */ 73 '.cpr-pastille{position:fixed;right:20px;bottom:20px;z-index:2147483000;display:flex;align-items:center;gap:10px;height:56px;padding:0 20px 0 8px;border:0;border-radius:999px;background:#fff;color:var(--cpr-b);font:inherit;font-weight:700;font-size:16px;cursor:pointer;box-shadow:0 18px 40px -12px rgba(25,94,171,.45),0 2px 6px rgba(0,0,0,.08);transition:padding .35s cubic-bezier(.2,.8,.2,1),box-shadow .25s,transform .25s;max-width:calc(100vw - 40px)}', 74 '.cpr-pastille:hover{box-shadow:0 22px 48px -12px rgba(242,98,35,.5),0 2px 6px rgba(0,0,0,.08);transform:translateY(-2px)}', 75 '.cpr-pastille:focus-visible{outline:3px solid var(--cpr-o);outline-offset:3px}', 76 '.cpr-pastille .cpr-lib{white-space:nowrap;overflow:hidden;max-width:220px;transition:max-width .35s cubic-bezier(.2,.8,.2,1),opacity .25s}', 77 '.cpr-pastille.cpr-replie{padding:0 8px}', 78 '.cpr-pastille.cpr-replie .cpr-lib{max-width:0;opacity:0}
78', 79 '.cpr-pastille.cpr-cache{transform:translateY(120px);pointer-events:none}', 80 '.cpr-soleil{width:40px;height:40px;flex:0 0 40px;display:block}', 81 '.cpr-soleil .cpr-rayons{transform-origin:50% 50%}', 82 '.cpr-pastille:hover .cpr-rayons{animation:cpr-tourne 6s linear infinite}', 83 '.cpr-pastille .cpr-rayons{animation:cpr-respire 20s ease-in-out infinite}', 84 '@keyframes cpr-tourne{to{transform:rotate(360deg)}}', 85 '@keyframes cpr-respire{0%,92%,100%{transform:scale(1) rotate(0)}95%{transform:scale(1.12) rotate(8deg)}}', 86 /* La fenêtre */ 87 '.cpr-fen{position:fixed;right:24px;bottom:24px;z-index:2147483001;width:400px;max-width:calc(100vw - 48px);height:640px;max-height:calc(100vh - 48px);display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 30px 80px -20px rgba(25,94,171,.45),0 4px 14px rgba(0,0,0,.08);overflow:hidden;opacity:0;transform:translateY(24px) scale(.98);pointer-events:none;transition:opacity .3s,transform .35s cubic-bezier(.2,.8,.2,1)}', 88 '.cpr-fen.cpr-ouverte{opacity:1;transform:none;pointer-events:auto}', 89 '.cpr-tete{display:flex;align-items:center;gap:12px;padding:14px 14px 12px 18px;border-bottom:1px solid #eef1f5;background:#fff}', 90 '.cpr-tete .cpr-soleil{width:38px;height:38px;flex-basis:38px}', 91 '.cpr-nom{font-weight:700;font-size:17px;color:var(--cpr-b)}', 92 '.cpr-etat{font-size:13px;color:var(--cpr-m);display:flex;align-items:center;gap:6px}', 93 '.cpr-etat:before{content:"";width:8px;height:8px;border-radius:50%;background:#27c88f;box-shadow:0 0 0 3px rgba(39,200,143,.2)}', 94 '.cpr-etat.cpr-humain:before{background:var(--cpr-o);box-shadow:0 0 0 3px rgba(242,98,35,.2)}', 95 '.cpr-tete .cpr-titre{flex:1;min-width:0}', 96 '.cpr-ico{width:44px;height:44px;border:0;border-radius:50%;background:transparent;color:var(--cpr-m);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;font:inherit}', 97 '.cpr-ico:hover{background:#f3f5f8;color:var(--cpr-t)}.cpr-ico:focus-visible{outline:3px solid var(--cpr-o);outline-offset:2px}', 98 '.cpr-ico.cpr-wa{color:#25d366;position:relative}.cpr-ico.cpr-wa:hover{background:#e9faf0}', 99 /* Infobulle au survol et au focus de l'icône WhatsApp (demande d'Olivier, 26/09/2026) : le pictogramme seul ne dit pas ce qu'il fait. */ 100 '.cpr-ico.cpr-wa:after{content:attr(data-bulle);position:absolute;top:calc(100% + 6px);right:0;white-space:nowrap;background:#23303f;color:#fff;font-size:12.5px;font-weight:600;line-height:1.2;padding:7px 10px;border-radius:10px;box-shadow:0 8px 20px -8px rgba(0,0,0,.4);opacity:0;transform:translateY(-4px);pointer-events:none;transition:opacity .15s,transform .15s;z-index:2}', 101 '.cpr-ico.cpr-wa:before{content:"";position:absolute;top:calc(100% - 1px);right:16px;border:6px solid transparent;border-bottom-color:#23303f;border-top:0;opacity:0;transition:opacity .15s;pointer-events:none}', 102 '.cpr-ico.cpr-wa:hover:after,.cpr-ico.cpr-wa:focus-visible:after,.cpr-ico.cpr-wa:hover:before,.cpr-ico.cpr-wa:focus-visible:before{opacity:1;transform:none}', 103 '.cpr-poignee{display:none;width:44px;height:5px;border-radius:3px;background:#d6dbe3;margin:8px auto 0}', 104 '.cpr-fil{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:18px 16px 8px;display:flex;flex-direction:column;gap:14px;background:linear-gradient(180deg,#fbfcfe,#fff 120px)}', 105 /* Un enfant qui défile (les cartes) a `min-height: 0` en colonne flex et s'écrasait à 12 px. */ 106 '.cpr-fil>*{flex-shrink:0}', 107 /* Messages du robot : sans bulle, filet orange, avatar soleil. */ 108 '.cpr-msg{display:flex;gap:10px;max-width:100%;animation:cpr-monte .3s ease-out}', 109 '@keyframes cpr-monte{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}', 110 '.cpr-msg.cpr-robot .cpr-av{width:28px;height:28px;flex:0 0 28px;margin-top:2px}', 111 '.cpr-msg.cpr-robot .cpr-corps{border-left:3px solid var(--cpr-o);padding:2px 0 2px 12px;font-size:15px}', 112 '.cpr-msg.cpr-humain .cpr-corps{border-left-color:var(--cpr-b)}', 113 '.cpr-msg.cpr-visiteur{justify-content:flex-end}', 114 '.cpr-msg.cpr-visiteur .cpr-corps{background:var(--cpr-b);color:#fff;border-radius:18px 18px 4px 18px;padding:10px 14px;max-width:85%;font-size:15px;white-space:pre-wrap;word-wrap:break-word}', 115 '.cpr-msg.cpr-systeme{justify-content:center}.cpr-msg.cpr-systeme .cpr-corps{font-size:12.5px;color:var(--cpr-m);text-align:center}', 116 '.cpr-corps p{margin:0 0 8px}.cpr-corps p:last-child{margin:0}.cpr-corps ul{margin:4px 0 8px 18px;padding:0}.cpr-corps li{margin:2px 0}', 117 '.cpr-corps a{color:var(--cpr-b);text-decoration:underline;text-underline-offset:2px}.cpr-corps strong{font-weight:700}', 118 '.cpr-qui{font-size:12px;color:var(--cpr-m);margin-bottom:2px}', 119 '.cpr-points{display:inline-flex;gap:4px;padding:6px 0}.cpr-points i{width:7px;height:7px;border-radius:50%;background:var(--cpr-o);opacity:.35;animation:cpr-point 1.2s infinite}.cpr-points i:nth-child(2){animation-delay:.2s}.cpr-points i:nth-child(3){animation-delay:.4s}', 120 '@keyframes cpr-point{0%,80%,100%{opacity:.35;transform:translateY(0)}40%{opacity:1;transform:translateY(-3px)}}', 121 /* Cartes produit */ 122 '.cpr-cartes{display:flex;gap:10px;overflow-x:auto;padding:4px 2px 8px 41px;margin-top:-6px;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none}', 123 '.cpr-cartes::-webkit-scrollbar{display:none}', 124 '.cpr-carte{flex:0 0 168px;scroll-snap-align:start;border:1px solid #e6eaf0;border-radius:16px;overflow:hidden;background:#fff;text-decoration:none;color:inherit;display:flex;flex-direction:column;transition:transform .2s,box-shadow .2s}', 125 '.cpr-carte:hover{transform:translateY(-2px);box-shadow:0 10px 24px -10px rgba(25,94,171,.4)}', 126 '.cpr-carte img{width:100%;height:100px;object-fit:cover;background:#f3f5f8;display:block}', 127 '.cpr-carte .cpr-cn{font-size:13px;font-weight:600;padding:8px 10px 2px;line-height:1.3;color:var(--cpr-t)}', 128 '.cpr-carte .cpr-cp{font-size:13px;color:var(--cpr-o);font-weight:700;padding:0 10px}', 129 '.cpr-carte .cpr-cv{font-size:12.5px;color:var(--cpr-b);padding:4px 10px 10px;font-weight:600}', 130 /* Suggestions */ 131 '.cpr-sugg{display:flex;flex-wrap:wrap;gap:8px;padding:0 0 4px 38px}', 132 '.cpr-sugg button{border:1px solid #ffd2bd;background:var(--cpr-ol);color:#b3441a;border-radius:999px;padding:9px 14px;font:inherit;font-size:14px;cursor:pointer;min-height:40px;text-align:left}', 133 '.cpr-sugg button:hover{background:#ffe3d3}.cpr-sugg button:focus-visible{outline:3px solid var(--cpr-o);outline-offset:2px}', 134 /* Carte de coordonnées */ 135 '.cpr-coord{margin-left:38px;border:1px solid #e6eaf0;border-radius:18px;padding:14px;background:var(--cpr-bl)}', 136 '.cpr-coord h4{margin:0 0 8px;font-size:15px;color:var(--cpr-b)}', 137 '.cpr-coord label{display:block;font-size:13px;color:var(--cpr-m);margin:8px 0 3px}', 138 '.cpr-coord input,.cpr-coord textarea{width:100%;border:1px solid #cfd6e0;border-radius:12px;padding:10px 12px;font:inherit;font-size:16px;background:#fff;color:var(--cpr-t)}', 139 '.cpr-coord input:focus,.cpr-coord textarea:focus{outline:2px solid var(--cpr-b);border-color:var(--cpr-b)}', 140 '.cpr-coord .cpr-btn{margin-top:12px;width:100%;height:44px;border:0;border-radius:12px;background:var(--cpr-o);color:#fff;font:inherit;font-size:15px;font-weight:700;cursor:pointer}', 141 '.cpr-coord .cpr-btn[disabled]{opacity:.6;cursor:default}.cpr-coord .cpr-err{color:#b3441a;font-size:13px;margin-top:8px}', 142 '.cpr-coord .cpr-lien{background:none;border:0;color:var(--cpr-m);font:inherit;font-size:13px;text-decoration:underline;cursor:pointer;margin-top:8px;padding:0}', 143 /* Rappel des coordonnées, après « Plus tard » : reste sous le fil jusqu'à l'envoi (constat d'Olivier, 26/09/2026). */ 144 '.cpr-rappel{display:none;padding:6px 12px 0;text-align:center}.cpr-rappel.cpr-visible{display:block}', 145 '.cpr-rappel button{border:1px solid #ffd2bd;background:var(--cpr-ol);color:#b3441a;border-radius:999px;padding:8px 14px;font:inherit;font-size:13.5px;cursor:pointer;min-height:36px}', 146 '.cpr-rappel button:hover{background:#ffe3d3}.cpr-rappel button:focus-visible{outline:3px solid var(--cpr-o);outline-offset:2px}', 147 /* Saisie */ 148 '.cpr-saisie{display:flex;align-items:flex-end;
148gap:8px;padding:10px 12px 8px;border-top:1px solid #eef1f5;background:#fff}', 149 '.cpr-saisie textarea{flex:1;resize:none;border:1px solid #d6dbe3;border-radius:22px;padding:11px 16px;font:inherit;font-size:16px;line-height:1.35;max-height:120px;min-height:46px;overflow-y:hidden;background:#fbfcfe;color:var(--cpr-t)}', 150 '.cpr-saisie textarea:focus{outline:none;border-color:var(--cpr-b);box-shadow:0 0 0 3px rgba(25,94,171,.15)}', 151 '.cpr-env{width:44px;height:44px;flex:0 0 44px;border:0;border-radius:50%;background:#e6eaf0;color:#9aa4b2;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;transition:background .2s,color .2s,transform .2s}', 152 '.cpr-env svg{width:22px;height:22px;margin-left:2px}.cpr-env:hover{background:#dde3ea;color:var(--cpr-t)}', 153 '.cpr-env.cpr-pret{background:var(--cpr-b);color:#fff}.cpr-env.cpr-pret:hover{background:#144b89;transform:scale(1.06)}', 154 '.cpr-env:focus-visible{outline:3px solid var(--cpr-o);outline-offset:2px}', 155 /* Micro (dictée par le navigateur, écran large seulement ; demande d'Olivier du 26/09/2026). */ 156 '.cpr-micro{display:none;width:44px;height:44px;flex:0 0 44px;border:0;border-radius:50%;background:transparent;color:#9aa4b2;cursor:pointer;align-items:center;justify-content:center;padding:0;position:relative}', 157 '.cpr-micro:hover{background:#f3f5f8;color:var(--cpr-t)}.cpr-micro:focus-visible{outline:3px solid var(--cpr-o);outline-offset:2px}', 158 '.cpr-micro.cpr-ecoute{color:#d3302f}.cpr-micro.cpr-ecoute:after{content:"";position:absolute;top:8px;right:8px;width:9px;height:9px;border-radius:50%;background:#d3302f;animation:cpr-pulse 1s ease-in-out infinite}', 159 '@keyframes cpr-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:.5}}', 160 '@media (min-width:640px){.cpr-x .cpr-micro.cpr-dispo{display:inline-flex}}', 161 '.cpr-mentions{font-size:11.5px;color:var(--cpr-m);text-align:center;padding:0 16px 10px;line-height:1.35}', 162 '.cpr-mentions a{color:var(--cpr-m)}.cpr-mentions button{background:none;border:0;color:var(--cpr-m);font:inherit;font-size:11.5px;text-decoration:underline;cursor:pointer;padding:0}', 163 '.cpr-erreur{font-size:13px;color:#b3441a;text-align:center}', 164 /* Mobile : une feuille sur toute la hauteur, poignée, clavier respecté. */ 165 '@media (max-width:639px){.cpr-fen{right:0;bottom:0;left:0;width:100%;max-width:100%;height:100dvh;max-height:100dvh;border-radius:24px 24px 0 0;transform:translateY(100%)}.cpr-fen.cpr-ouverte{transform:none}.cpr-poignee{display:block}.cpr-tete{padding-top:8px}.cpr-saisie{padding-bottom:calc(8px + env(safe-area-inset-bottom))}.cpr-pastille{right:16px;bottom:calc(16px + env(safe-area-inset-bottom))}.cpr-msg.cpr-visiteur .cpr-corps{max-width:88%}}', 166 '@media (prefers-reduced-motion:reduce){.cpr-x *{animation:none!important;transition:none!important}}', 167 ].join('\n'); 168 169 // ââ SVG du soleil (celui du logo, stylisé) âââââââââââââââââââââââââââââââââ 170 var NS = 'http://www.w3.org/2000/svg'; 171 function soleil(cls) { 172 var s = document.createElementNS(NS, 'svg'); 173 s.setAttribute('viewBox', '0 0 48 48'); s.setAttribute('aria-hidden', 'true'); s.setAttribute('class', 'cpr-soleil ' + (cls || '')); 174 var g = document.createElementNS(NS, 'g'); g.setAttribute('class', 'cpr-rayons'); g.setAttribute('fill', ORANGE); 175 for (var i = 0; i < 12; i++) { 176 var r = document.createElementNS(NS, 'path'); 177 // Rayons inégaux, comme sur le logo : un long, un court. 178 var L = i % 2 ? 7 : 11; 179 r.setAttribute('d', 'M24 3 L26.2 ' + (5 + L) + ' L21.8 ' + (5 + L) + ' Z'); 180 r.setAttribute('transform', 'rotate(' + (i * 30) + ' 24 24)'); 181 g.appendChild(r); 182 } 183 var d = document.createElementNS(NS, 'circle'); d.setAttribute('cx', '24'); d.setAttribute('cy', '24'); d.setAttribute('r', '8.5'); d.setAttribute('fill', ORANGE); d.setAttribute('class', 'cpr-disque'); 184 var c = document.createElementNS(NS, 'circle'); c.setAttribute('cx', '24'); c.setAttribute('cy', '24'); c.setAttribute('r', '4'); c.setAttribute('fill', '#fff'); 185 s.appendChild(g); s.appendChild(d); s.appendChild(c); 186 return s; 187 } 188 function icone(chemin, taille) { 189 var s = document.createElementNS(NS, 'svg'); 190 s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); s.setAttribute('width', taille || 22); s.setAttribute('height', taille || 22); 191 var p = document.createElementNS(NS, 'path'); p.setAttribute('d', chemin); p.setAttribute('fill', 'currentColor'); s.appendChild(p); 192 return s; 193 } 194 var ICO_CROIX = 'M18.3 5.7a1 1 0 0 0-1.4 0L12 10.6 7.1 5.7a1 1 0 1 0-1.4 1.4l4.9 4.9-4.9 4.9a1 1 0 1 0 1.4 1.4l4.9-4.9 4.9 4.9a1 1 0 0 0 1.4-1.4L13.4 12l4.9-4.9a1 1 0 0 0 0-1.4z'; 195 // L'avion en papier du bouton d'envoi (remarque d'Olivier du 26/09/2026 : le soleil gris ne se lisait pas comme « Envoyer »). 196 var ICO_ENVOI = 'M2.01 21 23 12 2.01 3 2 10l15 2-15 2z'; 197 var ICO_MICRO = 'M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5-3a1 1 0 0 1 2 0 7 7 0 0 1-6 6.92V20h2a1 1 0 0 1 0 2H9a1 1 0 0 1 0-2h2v-2.08A7 7 0 0 1 5 11a1 1 0 0 1 2 0 5 5 0 0 0 10 0z'; 198 var ICO_WA = 'M17.5 14.4c-.3-.1-1.8-.9-2-1-.3-.1-.5-.1-.7.1-.2.3-.8 1-1 1.2-.2.2-.3.2-.6.1-.3-.1-1.3-.5-2.4-1.5-.9-.8-1.5-1.8-1.7-2.1-.2-.3 0-.5.1-.6l.5-.6c.1-.2.2-.3.3-.5.1-.2 0-.4 0-.5l-.9-2.2c-.2-.6-.5-.5-.7-.5h-.6c-.2 0-.5.1-.8.4-.3.3-1 1-1 2.5s1.1 2.9 1.2 3.1c.1.2 2.1 3.2 5.1 4.5 2.5 1 3 .8 3.6.8.6-.1 1.8-.7 2-1.4.3-.7.3-1.3.2-1.4-.1-.2-.3-.2-.6-.4zM12 2a10 10 0 0 0-8.6 15.1L2 22l5-1.3A10 10 0 1 0 12 2zm0 18.2c-1.5 0-3-.4-4.3-1.2l-.3-.2-3 .8.8-2.9-.2-.3A8.2 8.2 0 1 1 12 20.2z'; 199 200 // ââ Construction âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 201 var el = {}; 202 function cree(tag, cls, texte) { var e = document.createElement(tag); if (cls) e.className = cls; if (texte != null) e.textContent = texte; return e; } 203 204 function construire() { 205 var style = document.createElement('style'); style.textContent = CSS; document.head.appendChild(style); 206 207 var pastille = cree('button', 'cpr-x cpr-pastille'); 208 pastille.type = 'button'; pastille.setAttribute('aria-label', T.pastille_aria); pastille.setAttribute('aria-haspopup', 'dialog'); 209 pastille.appendChild(soleil()); pastille.appendChild(cree('span', 'cpr-lib', T.pastille)); 210 pastille.addEventListener('click', ouvrir); 211 el.pastille = pastille; 212 213 var fen = cree('div', 'cpr-x cpr-fen'); fen.setAttribute('role', 'dialog'); fen.setAttribute('aria-modal', 'false'); fen.setAttribute('aria-label', T.dialogue); 214 el.poignee = cree('div', 'cpr-poignee'); fen.appendChild(el.poignee); 215 216 var tete = cree('div', 'cpr-tete'); tete.appendChild(soleil()); 217 var titre = cree('div', 'cpr-titre'); el.nom = cree('div', 'cpr-nom', nomRobot); el.etat = cree('div', 'cpr-etat', T.repond); 218 titre.appendChild(el.nom); titre.appendChild(el.etat); tete.appendChild(titre); 219 el.wa = cree('a', 'cpr-ico cpr-wa'); el.wa.setAttribute('aria-label', T.wa_aria); el.wa.setAttribute('data-bulle', T.wa_bulle); el.wa.title = T.wa_aria; el.wa.target = '_blank'; el.wa.rel = 'noopener';
219 el.wa.appendChild(icone(ICO_WA, 24)); el.wa.style.display = 'none'; tete.appendChild(el.wa); 220 var fermer = cree('button', 'cpr-ico'); fermer.type = 'button'; fermer.setAttribute('aria-label', T.fermer); fermer.appendChild(icone(ICO_CROIX)); fermer.addEventListener('click', fermerFen); tete.appendChild(fermer); 221 fen.appendChild(tete); 222 223 el.fil = cree('div', 'cpr-fil'); el.fil.setAttribute('aria-live', 'polite'); fen.appendChild(el.fil); 224 225 el.rappel = cree('div', 'cpr-rappel'); 226 var bRappel = cree('button', null, T.rappel); bRappel.type = 'button'; 227 bRappel.addEventListener('click', function () { el.rappel.classList.remove('cpr-visible'); afficherCoordonnees(); }); 228 el.rappel.appendChild(bRappel); fen.appendChild(el.rappel); 229 var saisie = cree('div', 'cpr-saisie'); 230 el.zone = cree('textarea'); el.zone.rows = 1; el.zone.maxLength = 1000; el.zone.placeholder = T.placeholder; el.zone.setAttribute('aria-label', T.message_aria); 231 el.zone.addEventListener('input', function () { ajusterZone(); el.env.className = 'cpr-env' + (el.zone.value.trim() ? ' cpr-pret' : ''); }); 232 el.zone.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); envoyer(); } }); 233 el.env = cree('button', 'cpr-env'); el.env.type = 'button'; el.env.setAttribute('aria-label', T.envoyer); el.env.title = T.envoyer; el.env.appendChild(icone(ICO_ENVOI, 22)); el.env.addEventListener('click', envoyer); 234 el.micro = cree('button', 'cpr-micro'); el.micro.type = 'button'; el.micro.setAttribute('aria-label', T.dicter); el.micro.title = T.dicter; el.micro.appendChild(icone(ICO_MICRO, 24)); 235 el.micro.addEventListener('click', basculerDictee); 236 saisie.appendChild(el.zone); saisie.appendChild(el.micro); saisie.appendChild(el.env); fen.appendChild(saisie); 237 238 el.mentions = cree('div', 'cpr-mentions'); 239 var m = cree('span', null, T.mentions + (Reconnaissance ? T.dictee_mention : '')); 240 el.mentions.appendChild(m); 241 if (politique) { var a = cree('a', null, T.savoir_plus); a.href = politique; a.target = '_blank'; a.rel = 'noopener'; el.mentions.appendChild(a); el.mentions.appendChild(document.createTextNode(' · ')); } 242 var neuf = cree('button', null, T.nouvelle); neuf.type = 'button'; neuf.addEventListener('click', recommencer); el.mentions.appendChild(neuf); 243 fen.appendChild(el.mentions); 244 el.fen = fen; 245 246 document.body.appendChild(pastille); document.body.appendChild(fen); 247 if (Reconnaissance) el.micro.classList.add('cpr-dispo'); 248 document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && ouvert) fermerFen(); }); 249 ecouterDefilement(); ecouterPoignee(); 250 } 251 252 function ajusterZone() { 253 el.zone.style.height = 'auto'; 254 el.zone.style.height = Math.min(el.zone.scrollHeight, 120) + 'px'; 255 el.zone.style.overflowY = el.zone.scrollHeight > 120 ? 'auto' : 'hidden'; 256 } 257 258 // Le bouton « retour en haut » des sites (ceilingo.fr : `#toTop` du module gototop, créé par jQuery 259 // après le chargement et montré au défilement) occupe le même coin que la pastille (constat 260 // d'Olivier, 26/09/2026, mobile et PC). Dès qu'un tel bouton est visible en bas à droite, la 261 // pastille et la fenêtre se décalent à sa gauche ; le décalage est gardé ensuite pour ne pas sauter. 262 // Relevé du 26/09/2026 : ceilingo.fr `#toTop` (module gototop), ceilingo-isolation.com `.btn-scroll-top` 263 // (thème PrestaShop 8), magicspeaker.fr `#scrollToTopBtn` et `.btn-scroll-top`. 264 var SELECTEURS_RETOUR_HAUT = '#toTop, #back-top, #backtotop, #back-to-top, #scrollUp, #scroll-top, #scrollToTopBtn, .gototop, .go-top, .back-to-top, .scroll-top, .scrolltop, .btn-scroll-top, [id*="totop" i], [class*="totop" i], [class*="scroll-top" i], [class*="scrolltop" i]'; 265 var decalage = Number(cfg.decalage) || 0; 266 function appliquerDecalage(px) { 267 if (!(px > decalage)) return; 268 decalage = px; 269 el.pastille.style.right = px + 'px'; 270 if (window.innerWidth >= 640) el.fen.style.right = px + 'px'; 271 } 272 function ajusterPourRetourHaut() { 273 var noeuds; 274 try { noeuds = document.querySelectorAll(SELECTEURS_RETOUR_HAUT); } catch (e) { return; } 275 for (var i = 0; i < noeuds.length; i++) { 276 var n = noeuds[i]; 277 if (n === el.pastille || el.fen.contains(n)) continue; 278 var r = n.getBoundingClientRect(); 279 if (!(r.width > 0 && r.height > 0)) continue; 280 // Seulement un bouton posé dans le coin bas droit ; ailleurs, rien à éviter. 281 if (r.right < window.innerWidth - 160 || r.bottom < window.innerHeight - 160) continue; 282 appliquerDecalage(Math.round(window.innerWidth - r.left + 12)); 283 alignerSurBouton(n, r); 284 return; 285 } 286 } 287 // Sur PC (640 px et plus), la pastille se centre à la hauteur du bouton (demande d'Olivier du 26/09/2026 : 288 // les trois sites posent leur bouton à des hauteurs différentes). Seulement quand le bouton est vraiment 289 // visible : caché, il est souvent décalé par une transition (translateY) et sa mesure mentirait. Sur mobile, 290 // la feuille et la pastille gardent leur place. 291 function alignerSurBouton(n, r) { 292 if (window.innerWidth < 640) return; 293 var cs; 294 try { cs = window.getComputedStyle(n); } catch (e) { return; } 295 if (cs.visibility === 'hidden' || Number(cs.opacity) < 0.99) return; 296 var h = el.pastille.offsetHeight || 56; 297 var bas = Math.round(window.innerHeight - r.bottom + (r.height - h) / 2); 298 if (bas < 0 || bas > 200) return; 299 if (el.pastille.style.bottom !== bas + 'px') el.pastille.style.bottom = bas + 'px'; 300 } 301 302 // La pastille se replie sur le seul soleil quand on descend, se redéploie quand on remonte. 303 // Sur ceilingo-isolation.com et magicspeaker.fr (thème PrestaShop 8), c'est <body> qui défile (hauteur 100vh, 304 // overflow auto) : window.pageYOffset reste à 0 et l'événement scroll n'atteint pas window (constat d'Olivier du 305 // 26/09/2026, la pastille ne se repliait pas). Ãcoute en phase de capture sur document, qui voit tout défilement, 306 // et position lue sur ce qui défile (window, body ou l'élément visé). 307 function positionDefilement(cible) { 308 var y = window.pageYOffset || 0; 309 if (document.body && document.body.scrollTop > y) y = document.body.scrollTop; 310 if (document.documentElement && document.documentElement.scrollTop > y) y = document.documentElement.scrollTop; 311 if (cible && cible.nodeType === 1 && cible.scrollTop > y) y = cible.scrollTop; 312 return y; 313 } 314 var releveDiffere = null; 315 function ecouterDefilement() { 316 var dernier = positionDefilement(null), attente = false; 317 if (decalage) { var d0 = decalage; decalage = 0; appliquerDecalage(d0); } 318 document.addEventListener('scroll', function (e) { 319 if (attente) return; attente = true; 320 var cible = e && e.target && e.target.nodeType === 1 ? e.target : null; 321 // Un défilement interne à la bulle (le fil des messages) ne replie rien. 322 if (cible && el.fen && el.fen.contains(cible)) { attente = false; return; } 323 // setTimeout et non requestAnimationFrame : ce dernier ne tourne pas dans un onglet caché et laissait 324 // `attente` levé ; le prochain défilement, onglet revenu devant, ne repliait plus rien. 325 setTimeout(function () { 326 var y = positionDefilement(cible); 327 if (y > dernier + 8 && y > 200) el.pastille.classList.add('cpr-replie'); 328 else if (y < dernier - 8) el.pastille.classList.remove('cpr-replie'); 329 dernier = y; attente = false; 330 ajusterPourRetourHaut(); 331 // Le bouton « retour en haut » finit d'apparaître après le dernier événement : un relevé différé. 332 if (releveDiffere) clearTimeout(releveDiffere); 333 releveDiffere = setTimeout(ajusterPourRetourHaut, 700); 334 }); 335 }, { passive: true, capture: true });
336 // Le bouton peut apparaître avec un fondu après le défilement : deux relevés différés. 337 setTimeout(ajusterPourRetourHaut, 800); 338 setTimeout(ajusterPourRetourHaut, 2500); 339 } 340 341 // Sur mobile, un glissement vers le bas depuis la poignée ferme la feuille. 342 function ecouterPoignee() { 343 var y0 = null; 344 el.poignee.addEventListener('touchstart', function (e) { y0 = e.touches[0].clientY; }, { passive: true }); 345 el.poignee.addEventListener('touchmove', function (e) { if (y0 !== null && e.touches[0].clientY - y0 > 80) { y0 = null; fermerFen(); } }, { passive: true }); 346 el.poignee.addEventListener('touchend', function () { y0 = null; }); 347 } 348 349 // ââ Ouverture, fermeture âââââââââââââââââââââââââââââââââââââââââââââââââââ 350 function ouvrir() { 351 if (!el.fen) return; 352 ouvert = true; 353 el.fen.classList.add('cpr-ouverte'); el.pastille.classList.add('cpr-cache'); 354 if (!chargeUneFois) charger(); else { el.zone.focus(); planifierPoll(); } 355 } 356 function fermerFen() { 357 ouvert = false; 358 el.fen.classList.remove('cpr-ouverte'); el.pastille.classList.remove('cpr-cache'); 359 clearTimeout(minuteriePoll); el.pastille.focus(); 360 } 361 function recommencer() { 362 effacerGarde(); jeton = null; dernierId = 0; idsAffiches = {}; premierMessageEnvoye = false; mode = 'robot'; chargeUneFois = false; 363 el.rappel.classList.remove('cpr-visible'); 364 while (el.fil.firstChild) el.fil.removeChild(el.fil.firstChild); 365 charger(); 366 } 367 368 function charger() { 369 var garde = lireGarde(); 370 // Une discussion vit dans une langue : si la page est dans une autre, on en ouvre une neuve. 371 if (garde && garde.langue && garde.langue !== LANG) { effacerGarde(); garde = null; } 372 afficherPoints(); 373 appeler('POST', '/ouvrir', { page: adressePage(), langue: LANG, jeton: garde ? garde.jeton : '', jeton_visite: jetonVisite() }, function (statut, d) { 374 retirerPoints(); 375 // 404 : le robot n'est pas activé sur l'instance (ou le site n'est pas réglé). La pastille 376 // disparaît sans un mot plutôt que d'afficher une erreur à chaque visiteur. 377 if (statut === 404) { fermerFen(); el.pastille.style.display = 'none'; return; } 378 if (statut !== 200 || !d || !d.jeton) { if (statut === 429) erreur(T.trop_vite); else erreur(T.injoignable); return; } 379 chargeUneFois = true; 380 jeton = d.jeton; ecrireGarde(); 381 nomRobot = d.nom || nomRobot; el.nom.textContent = nomRobot; 382 whatsapp = String(d.whatsapp || '').replace(/[^0-9]/g, ''); 383 if (whatsapp) { el.wa.href = 'https://wa.me/' + whatsapp; el.wa.style.display = ''; } 384 poserMode(d.mode || 'robot'); 385 var messages = d.messages || []; 386 if (messages.length) { 387 premierMessageEnvoye = true; 388 messages.forEach(afficherMessage); 389 } else if (d.accueil) { 390 afficherMessage({ role: 'robot', texte: d.accueil }); 391 suggestions = d.suggestions || []; 392 afficherSuggestions(); 393 } 394 if (d.etat === 'traitee' || d.etat === 'sans_suite') { /* fil clos côté humain : on repart sur un nouveau au prochain message */ } 395 defiler(); el.zone.focus(); planifierPoll(); 396 }); 397 } 398 399 var prenomHumain = ''; 400 function poserMode(m) { 401 mode = m === 'humain' ? 'humain' : 'robot'; 402 // Court : sur un téléphone, la ligne d'état n'a que 180 px. 403 el.etat.textContent = mode === 'humain' ? (prenomHumain || T.conseiller) + T.vous_repond : T.repond; 404 el.etat.className = 'cpr-etat' + (mode === 'humain' ? ' cpr-humain' : ''); 405 } 406 407 // ââ Rendu des messages âââââââââââââââââââââââââââââââââââââââââââââââââââââ 408 // Mini-rendu sûr : paragraphes, listes « - », **gras**, liens [texte](url) et adresses nues vers 409 // les seuls domaines Ceilingo. Tout passe par des nÅuds texte, jamais par innerHTML. 410 function domaineAutorise(url) { 411 var m = /^https?:\/\/([^\/?#]+)/i.exec(url); if (!m) return false; 412 var h = m[1].toLowerCase().replace(/^www\./, ''); 413 for (var i = 0; i < DOMAINES_LIENS.length; i++) if (h === DOMAINES_LIENS[i] || h.slice(-DOMAINES_LIENS[i].length - 1) === '.' + DOMAINES_LIENS[i]) return true; 414 return false; 415 } 416 function ligneEnLigne(conteneur, texte) { 417 var re = /\[([^\]]{1,120})\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>"')]+)|\*\*([^*]{1,200})\*\*/g, m, pos = 0; 418 while ((m = re.exec(texte))) { 419 if (m.index > pos) conteneur.appendChild(document.createTextNode(texte.slice(pos, m.index))); 420 if (m[4] != null) { conteneur.appendChild(cree('strong', null, m[4])); } 421 else { 422 // Une adresse nue s'affiche courte (« ceilingo.fr/⦠») : l'adresse complète d'une fiche 423 // produit fait quatre lignes dans une fenêtre de 400 px. 424 var url = m[2] || m[3], lib = m[1] || url.replace(/^https?:\/\/(www\.)?([^\/?#]+).*$/, function (_, w, h) { return h + (url.indexOf('/', 8) > 0 && url.indexOf('/', 8) < url.length - 1 ? '/â¦' : ''); }); 425 if (domaineAutorise(url)) { var a = cree('a', null, lib); a.href = url; a.target = '_blank'; a.rel = 'noopener'; conteneur.appendChild(a); } 426 else conteneur.appendChild(document.createTextNode(m[1] ? m[1] : url)); 427 } 428 pos = re.lastIndex; 429 } 430 if (pos < texte.length) conteneur.appendChild(document.createTextNode(texte.slice(pos))); 431 } 432 function rendreTexte(conteneur, texte) { 433 var blocs = String(texte || '').replace(/\r/g, '').split(/\n{2,}/); 434 blocs.forEach(function (b) { 435 var lignes = b.split('\n'), ul = null;
436 lignes.forEach(function (l) { 437 var li = /^\s*[-â¢*]\s+(.*)$/.exec(l); 438 if (li) { if (!ul) { ul = cree('ul'); conteneur.appendChild(ul); } var e = cree('li'); ligneEnLigne(e, li[1]); ul.appendChild(e); } 439 else if (l.trim()) { ul = null; var p = cree('p'); ligneEnLigne(p, l); conteneur.appendChild(p); } 440 }); 441 }); 442 } 443 444 var idsAffiches = {}; 445 function afficherMessage(msg, anime) { 446 if (msg.id) { if (idsAffiches[msg.id]) return; idsAffiches[msg.id] = 1; } 447 if (msg.id && msg.id > dernierId) dernierId = msg.id; 448 var role = msg.role === 'humain' || msg.role === 'systeme' || msg.role === 'visiteur' ? msg.role : 'robot'; 449 var m = cree('div', 'cpr-msg cpr-' + role), corps = cree('div', 'cpr-corps'); 450 if (role === 'robot' || role === 'humain') { 451 m.appendChild(soleil('cpr-av')); 452 if (role === 'humain' && msg.prenom) { corps.appendChild(cree('div', 'cpr-qui', msg.prenom)); if (!prenomHumain) { prenomHumain = couper(msg.prenom, 30); poserMode(mode); } } 453 } 454 if (role === 'visiteur') corps.textContent = msg.texte; 455 else if (role === 'systeme') corps.textContent = msg.texte; 456 else if (anime && !reduit) taper(corps, msg.texte); 457 else rendreTexte(corps, msg.texte); 458 m.appendChild(corps); el.fil.appendChild(m); 459 if (msg.produits && msg.produits.length) afficherCartes(msg.produits); 460 defiler(); 461 } 462 463 // Le robot « tape » : le texte se révèle par blocs de mots, puis le rendu riche remplace le brut. 464 function taper(corps, texte) { 465 var mots = String(texte || '').split(/(\s+)/), i = 0, p = cree('p'); corps.appendChild(p); 466 (function suite() { 467 if (i >= mots.length) { while (corps.firstChild) corps.removeChild(corps.firstChild); rendreTexte(corps, texte); defiler(); return; } 468 p.textContent += mots[i] + (mots[i + 1] || ''); i += 2; defiler(); 469 setTimeout(suite, 20); 470 })(); 471 } 472 473 function afficherCartes(produits) { 474 var c = cree('div', 'cpr-cartes'); 475 produits.slice(0, 6).forEach(function (p) { 476 if (!p || !domaineAutorise(String(p.url || ''))) return; 477 var a = cree('a', 'cpr-carte'); a.href = p.url; a.target = '_blank'; a.rel = 'noopener'; 478 if (p.image && domaineAutorise(String(p.image))) { var img = document.createElement('img'); img.src = p.image; img.alt = ''; img.loading = 'lazy'; a.appendChild(img); } 479 a.appendChild(cree('div', 'cpr-cn', couper(p.nom, 70))); 480 if (p.prix) a.appendChild(cree('div', 'cpr-cp', couper(p.prix, 30))); 481 a.appendChild(cree('div', 'cpr-cv', T.voir)); 482 c.appendChild(a); 483 }); 484 if (c.childNodes.length) el.fil.appendChild(c); 485 } 486 487 function afficherSuggestions() { 488 retirerSuggestions(); 489 if (!suggestions.length || premierMessageEnvoye) return; 490 var s = cree('div', 'cpr-sugg'); s.setAttribute('aria-label', T.suggestions); 491 suggestions.slice(0, 4).forEach(function (t) { 492 var b = cree('button', null, couper(t, 80)); b.type = 'button'; 493 b.addEventListener('click', function () { el.zone.value = t; envoyer(); }); 494 s.appendChild(b); 495 }); 496 el.fil.appendChild(s); el.sugg = s; 497 } 498 function retirerSuggestions() { if (el.sugg && el.sugg.parentNode) el.sugg.parentNode.removeChild(el.sugg); el.sugg = null; } 499 500 function afficherPoints() { 501 retirerPoints(); 502 var m = cree('div', 'cpr-msg cpr-robot'); m.appendChild(soleil('cpr-av')); 503 var c = cree('div', 'cpr-corps'), pts = cree('span', 'cpr-points'); pts.appendChild(cree('i')); pts.appendChild(cree('i')); pts.appendChild(cree('i')); 504 c.appendChild(pts); m.appendChild(c); el.fil.appendChild(m); el.points = m; defiler(); 505 } 506 function retirerPoints() { if (el.points && el.points.parentNode) el.points.parentNode.removeChild(el.points); el.points = null; } 507 function erreur(texte) { var e = cree('div', 'cpr-erreur', texte); el.fil.appendChild(e); defiler(); setTimeout(function () { if (e.parentNode) e.parentNode.removeChild(e); }, 6000); } 508 function defiler() { el.fil.scrollTop = el.fil.scrollHeight; } 509 510 // ââ Dictée âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 511 // Reconnaissance vocale du navigateur (Chrome, Edge, Safari ; absente de Firefox, où le micro ne 512 // s'affiche pas). Le robot ne reçoit jamais la voix : seulement le texte que le visiteur relit et 513 // envoie lui-même. Ãcran large seulement : sur mobile, le clavier dicte déjà . 514 var Reconnaissance = window.SpeechRecognition || window.webkitSpeechRecognition || null; 515 var reco = null, texteAvantDictee = ''; 516 function arreterDictee() { 517 if (reco) { try { reco.stop(); } catch (e) {} } 518 reco = null; el.micro.classList.remove('cpr-ecoute'); el.micro.setAttribute('aria-label', T.dicter); 519 } 520 function basculerDictee() { 521 if (!Reconnaissance) return; 522 if (reco) { arreterDictee(); return; } 523 try { 524 reco = new Reconnaissance(); 525 reco.lang = (document.documentElement.lang || 'fr').indexOf('fr') === 0 ? 'fr-FR' : (document.documentElement.lang || 'fr'); 526 reco.interimResults = true; reco.continuous = false; reco.maxAlternatives = 1; 527 texteAvantDictee = el.zone.value ? el.zone.value.replace(/\s+$/, '') + ' ' : ''; 528 reco.onresult = function (ev) { 529 var t = ''; 530 for (var i = 0; i < ev.results.length; i++) t += ev.results[i][0].transcript; 531 el.zone.value = couper(texteAvantDictee + t, 1000); ajusterZone(); 532 el.env.className = 'cpr-env' + (el.zone.value.trim() ? ' cpr-pret' : ''); 533 }; 534 reco.onerror = function () { arreterDictee(); }; 535 reco.onend = function () { arreterDictee(); el.zone.focus(); }; 536 reco.start(); 537 el.micro.classList.add('cpr-ecoute'); el.micro.setAttribute('aria-label', T.arreter_dictee); 538 } catch (e) { arreterDictee(); } 539 } 540 541 // ââ Envoi ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 542 function envoyer() { 543 var texte = el.zone.value.trim(); 544 if (!texte || enAttente || !jeton) return; 545 enAttente = true; premierMessageEnvoye = true; retirerSuggestions(); if (reco) arreterDictee(); 546 el.zone.value = ''; ajusterZone(); el.env.className = 'cpr-env'; 547 afficherMessage({ role: 'visiteur', texte: texte }); 548 if (mode === 'robot') afficherPoints();
549 appeler('POST', '/' + jeton + '/message', { texte: couper(texte, 1000), page: adressePage(), langue: LANG }, function (statut, d) { 550 enAttente = false; retirerPoints(); 551 if (statut === 404) { effacerGarde(); jeton = null; erreur(T.expiree); recommencer(); return; } 552 if (statut === 429) { erreur(T.trop_vite); return; } 553 if (statut !== 200 || !d) { erreur(T.injoignable); return; } 554 if (d.mode) poserMode(d.mode); 555 // Le serveur nomme l'identifiant `message_id` (et `dernier_id`) : sans lui, le relevé 556 // suivant réaffichait la même réponse (doublon vu par Olivier le 26/09/2026). 557 var idReponse = Number(d.message_id || d.dernier_id || d.id) || 0; 558 if (d.attente) { if (idReponse > dernierId) dernierId = idReponse; poserMode('humain'); planifierPoll(); return; } 559 afficherMessage({ id: idReponse, role: 'robot', texte: d.reponse, produits: d.produits }, true); 560 if (d.demander_coordonnees) setTimeout(afficherCoordonnees, reduit ? 0 : 400); 561 planifierPoll(); 562 }); 563 } 564 565 // ââ Coordonnées ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 566 function afficherCoordonnees() { 567 if (el.coord) return; 568 var c = cree('div', 'cpr-coord'); el.coord = c; 569 c.appendChild(cree('h4', null, T.rappelle)); 570 function champ(lib, type, nom, auto) { c.appendChild(cree('label', null, lib)); var i = cree('input'); i.type = type; i.name = nom; i.autocomplete = auto || 'on'; i.maxLength = 120; c.appendChild(i); return i; } 571 var nom = champ(T.nom, 'text', 'nom', 'name'), email = champ(T.courriel, 'email', 'email', 'email'), tel = champ(T.tel, 'tel', 'telephone', 'tel'); 572 c.appendChild(cree('label', null, T.souhait)); 573 var souhait = cree('textarea'); souhait.rows = 2; souhait.maxLength = 500; c.appendChild(souhait); 574 var btn = cree('button', 'cpr-btn', T.envoyer_coord); btn.type = 'button'; c.appendChild(btn); 575 var err = cree('div', 'cpr-err'); c.appendChild(err); 576 var plus = cree('button', 'cpr-lien', T.plus_tard); plus.type = 'button'; 577 plus.addEventListener('click', function () { 578 c.parentNode.removeChild(c); el.coord = null; 579 afficherMessage({ role: 'robot', texte: T.pas_de_souci }); 580 el.rappel.classList.add('cpr-visible'); 581 }); 582 c.appendChild(plus); 583 btn.addEventListener('click', function () { 584 err.textContent = ''; 585 if (nom.value.trim().length < 2) { err.textContent = T.nom_svp; nom.focus(); return; } 586 if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email.value.trim())) { err.textContent = T.courriel_svp; email.focus(); return; } 587 btn.disabled = true; 588 appeler('POST', '/' + jeton + '/coordonnees', { nom: nom.value.trim(), email: email.value.trim(), telephone: tel.value.trim(), souhait: souhait.value.trim() }, function (statut, d) { 589 btn.disabled = false; 590 if (statut === 200 && d && d.ok) { c.parentNode.removeChild(c); el.coord = null; el.rappel.classList.remove('cpr-visible'); afficherMessage({ role: 'robot', texte: d.message || T.merci }, true); return; } 591 if (statut === 422 && d && d.message) { err.textContent = d.message; return; } 592 err.textContent = statut === 429 ? T.deja : T.envoi_impossible; 593 }); 594 }); 595 el.fil.appendChild(c); defiler(); nom.focus(); 596 } 597 598 // ââ Relevé (main reprise par un humain) ââââââââââââââââââââââââââââââââââââ 599 function planifierPoll() { 600 clearTimeout(minuteriePoll); 601 if (!ouvert || !jeton) return; 602 minuteriePoll = setTimeout(relever, mode === 'humain' ? 4000 : 20000); 603 } 604 function relever() { 605 if (!ouvert || !jeton || document.hidden) { planifierPoll(); return; } 606 appeler('GET', '/' + jeton + '/depuis/' + dernierId, null, function (statut, d) { 607 if (statut === 200 && d) { 608 if (d.mode) poserMode(d.mode); 609 (d.messages || []).forEach(function (m) { if (m.id > dernierId && m.role !== 'visiteur') afficherMessage(m, m.role === 'robot'); }); 610 } 611 planifierPoll(); 612 });
613 } 614 615 // ââ Départ âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 616 function demarrer() { construire(); if (cfg.ouvrir) ouvrir(); } 617 if (document.body) demarrer(); else document.addEventListener('DOMContentLoaded', demarrer); 618 619 // Pour la page d'essai : état lisible, jamais dans la console. 620 window.CeilingoRobot = { version: '1.37', ouvrir: ouvrir, fermer: fermerFen, etat: function () { return { jeton: jeton, ouvert: ouvert, mode: mode, dernierId: dernierId }; } }; 621})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.