PageSourceSearch

https://www.ceilingo.fr/js/robot-ceilingo.js?v=20260926l

js ceilingo.fr collected 2026-10-09 22:12:17 UTC 58,840 bytes, 621 lines download raw bytes

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.