@pmeweb/plugin-chat 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INSTALL.md CHANGED
@@ -149,8 +149,8 @@ package's, approved once.
149
149
  and photos paragraphs when those keys are set), only while `enabled` is true: it needs
150
150
  `@pmeweb/consent` 1.1.0 or later (manifest `activeWhen`; 1.0.0 would print it switched off, and
151
151
  check-plugins refuses that).
152
- 10. **Not in `pmeweb.json` `forms`**: the chat's Turnstile widget renders only once the panel is
153
- open, so the portal's form-health browser check would find nothing on a page load.
152
+ 10. **Not in `pmeweb.json` `forms`**: the chat's Turnstile widget renders only after the visitor
153
+ interacts with the page or the opener appears (20 s), so the portal's form-health browser check would find nothing on a page load.
154
154
  11. `npm run build && node ../../a11y-tools/check-plugins.mjs .` until PASS; `npm run check`.
155
155
  12. **Portal** (« Modules › Assistant virtuel »): PmeWeb sets `alertEmails`
156
156
  (`alertEmails` is needed before the switch goes on: pmeweb-chat refuses a site without it), `alertPhone` if texts are on, and
package/README.md CHANGED
@@ -52,8 +52,49 @@ file ».
52
52
  face (`avatar`: photos, illustration or a « Sans visage » icon), the icon's colour (`avatarBg`,
53
53
  default the site's accent) and the name (`botName`, which ships with « Publier »). `logo` stays
54
54
  PmeWeb's, set at install.
55
- - **Nothing loads with the page.** The bubble is HTML and CSS; the panel, Turnstile and the socket
56
- arrive when the visitor presses, hovers, focuses or types on it.
55
+ - **Nothing loads with the page.** The bubble is HTML and CSS (the inline trigger, under the
56
+ manifest's `inlineBudget`). The panel's code (`widget.js`) arrives on the visitor's first sign of
57
+ life anywhere on the page (a scroll, a pointer moving, a press, a key or a focus) or when the
58
+ opener appears (20 s), whichever comes first. The socket opens only on the first message.
59
+ - **Turnstile starts before the panel opens, never in a test** (operator, 2026-11-13). Loaded when
60
+ the panel opened, the challenge had no time to observe the visitor and often showed its
61
+ « Vérifiez que vous êtes humain » box. It now starts once `widget.js` is in AND the page has
62
+ painted (`load`, first contentful paint, LCP, whichever is latest) plus `WARM_MS` (3 s), or at
63
+ once on a press or a focus in the chat, or when the panel opens, whichever comes first. A loaded
64
+ `api.js` racing the first paint costs up to 30 PageSpeed points (`@pmeweb/turnstile`'s README);
65
+ Lighthouse and PageSpeed never scroll, move a pointer or type, so they fetch neither `widget.js`
66
+ nor `api.js` before the opener's 20 s. Measured on a demo page with the chat on (1.3.1,
67
+ Lighthouse 12.6.1 mobile, 3 runs): no request to `challenges.cloudflare.com` or `widget.js`,
68
+ performance 100. In Chromium at 390 px: a visitor scrolling at 1 s gets `widget.js` at 1.0 s,
69
+ `api.js` at 4.0 s; one who never touches the page, `widget.js` with the opener at 20.0 s,
70
+ `api.js` at 23.0 s; in both the token is minted before the panel opens.
71
+ - **The Cloudflare box shows only when Cloudflare asks.** The widgets (`interaction-only`) are
72
+ rendered by the chat itself, explicitly (options and callbacks as an object), exactly once per
73
+ box (a `data-pmw-rendered` mark is the guard). They carry no `cf-turnstile` class, so `api.js`'s
74
+ implicit pass, which renders a contact form's `<Turnstile>`, never reaches them, and the chat
75
+ never renders, resets or alters a widget it does not own. The composer's box mints its token in
76
+ the closed panel, which stays laid out (transparent, `inert`, no pointer events) so the box has
77
+ a real size; the contact form's box is rendered once that form is shown. Each sits in its own
78
+ row, collapsed (the box transparent and out of the flow, full width) until Turnstile's
79
+ `before-interactive` callback opens it; its success callback collapses it again (« Succès ! »
80
+ goes away, 0.25 s, at once under reduced motion). In the composer the row sits above the field,
81
+ so the field never moves. An unused token is refreshed by Turnstile before its 300 s expiry; a
82
+ used one is replaced just before the next post (`turnstile.reset(id)`, never while its row is
83
+ asking).
84
+ - **A failed challenge is retried once, silently.** A retryable error code (300*, 600*, 110600,
85
+ 110620) resets the widget once without showing anything. A second failure keeps the row
86
+ collapsed; the next post shows « L’envoi n’a pas abouti. Réessayez. » without posting, and the
87
+ one after starts a fresh challenge. Measured with Cloudflare's always-block test key (which
88
+ answers 600010, the code a desktop Chrome in DevTools device emulation gets): one reset, one
89
+ second 600010, no box shown.
90
+ - **The first message reads like a reply.** The typing dots show at once while the conversation
91
+ starts (token, `/api/chat/start`, socket); « Un instant… » joins them only past 8 s. Nothing is
92
+ started before the visitor writes: pmeweb-chat counts every start toward the visitor's
93
+ conversations of the day, so only the token is prepared ahead.
94
+ - **The thread stays at the bottom** while the visitor is there (within 40 px): streamed text, the
95
+ final message, the dots, a photo once decoded, the takeover and hand-back lines, the end block, the
96
+ composer growing. One smooth scroll per frame (instant under reduced motion). A visitor who
97
+ scrolled up to read stays put; sending a message always scrolls down.
57
98
  - **Openers.** One line per page, from the site's dictionary (`chat.openers`, `chat.defaultOpener`). At most 50 characters (two lines on a phone): a longer one fails the build, naming it.
58
99
  Shown once per visit (20 s, 30 % of the page, or exit intent with a mouse), never while the panel
59
100
  is open, never while the footer is in view (it waits for the next of those triggers). On a phone
@@ -70,7 +111,9 @@ file ».
70
111
  (the first one wins over the footer). Never while the panel is open or while focus is in the
71
112
  bubble or the opener (a focusout re-checks). Reduced motion: hidden and shown at once.
72
113
  - **Never passing for a person.** « Assistant virtuel » is always in text; the green dot appears
73
- only when the owner has taken over, with the owner's name.
114
+ only when the owner has taken over, with the owner's name. Where the owner takes over, the thread
115
+ shows « {nom} a pris le relais »; where they hand back (« Rendre à l’assistant »), « L’assistant
116
+ virtuel a repris la conversation » (the dot muted), live and on replay, once per hand-back.
74
117
  - **The socket.** A drop (1006, a forced deploy of pmeweb-chat) reconnects with backoff 1 to 30 s
75
118
  and catches up from the last message seen; a message typed meanwhile is queued and sent on open,
76
119
  and after 3 sends without an echo shows « Non envoyé, Réessayer ». A 44xx close never reconnects.
@@ -1 +1 @@
1
- (()=>{function S({doc:r=document,hostname:n=globalThis.location?.hostname??""}={}){let a=r.getElementById("pmw-hosts");if(!a)return!1;let s;try{s=JSON.parse(a.textContent??"")}catch{return!1}if(!Array.isArray(s))return!1;let c=String(n).toLowerCase();return s.some(e=>typeof e=="string"&&e.toLowerCase()===c)}var A=["pointerdown","pointerenter","focusin","keydown"];function T(r,n,{events:a=A}={}){let s=!1,c=()=>{if(!s){s=!0;for(let e of a)r.removeEventListener(e,c);n()}};for(let e of a)r.addEventListener(e,c,{passive:!0});return{run:c}}var w="pmw-chat-opener",O=2e4,I=.3;function E({doc:r=document,win:n=window,hostname:a=n.location.hostname,importLazy:s=e=>import(e),onInteraction:c=T}={}){let e=r.getElementById("pmw-chat");if(!e)return null;e.hidden=!1;let l=e.querySelector(".pmw-chat__teaser"),u=null,d=()=>u??(u=s(e.dataset.pmwLazy).then(t=>t.mountChat(e),t=>{throw u=null,t}));c(e,()=>d().catch(()=>{}));let m=S({doc:r,hostname:a});if(!m){let t=()=>{let o=new URLSearchParams(n.location.hash.slice(1)).get("pmw-chat");o==="panel"&&!e.hasAttribute("data-open")?d().then(i=>i.open(),()=>{}):o==="bubble"&&u&&u.then(i=>i.close(),()=>{})};t(),n.addEventListener("hashchange",t)}let h=!1,v=(t=r.activeElement)=>{let o=h&&!e.hasAttribute("data-open")&&!e.contains(t);e.toggleAttribute("inert",o)},y=r.querySelector("[data-pmw-chat-avoid]")??[...r.querySelectorAll("footer")].pop();m&&y&&(new n.IntersectionObserver(t=>{h=t.at(-1).isIntersecting,v()}).observe(y),e.addEventListener("focusout",t=>v(t.relatedTarget)));let g=n.sessionStorage,f=!1;try{f=!!g?.getItem(w)}catch{}let b=(t,o,i)=>t.addEventListener(o,x=>f||i(x),{passive:!0}),L=()=>{f=!0;try{g?.setItem(w,"1")}catch{}},p=()=>{f||h||!l||e.hasAttribute("data-open")||(L(),l.hidden=!1)};return e.addEventListener("click",t=>{let o=t.target.closest?.("[data-pmw-chat-open],[data-pmw-chat-dismiss]");o&&(l&&(l.hidden=!0),L(),o.hasAttribute("data-pmw-chat-open")&&d().then(i=>i.open(),()=>{}))}),!f&&l&&m&&(n.setTimeout(p,O),b(n,"scroll",()=>{let t=r.documentElement.scrollHeight-n.innerHeight;t>0&&n.scrollY/t>=I&&p()}),n.matchMedia?.("(pointer: fine)").matches&&b(r,"mouseout",t=>{!t.relatedTarget&&t.clientY<=0&&p()})),{load:d,show:p}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>E()):E();})();
1
+ (()=>{function x({doc:o=document,hostname:n=globalThis.location?.hostname??""}={}){let a=o.getElementById("pmw-hosts");if(!a)return!1;let s;try{s=JSON.parse(a.textContent??"")}catch{return!1}if(!Array.isArray(s))return!1;let c=String(n).toLowerCase();return s.some(t=>typeof t=="string"&&t.toLowerCase()===c)}var v=["pointerdown","pointerenter","focusin","keydown"];function A(o,n,{events:a=v}={}){let s=!1,c=()=>{if(!s){s=!0;for(let t of a)o.removeEventListener(t,c);n()}};for(let t of a)o.addEventListener(t,c,{passive:!0});return{run:c}}var I="pmw-chat-opener",C=2e4,N=.3,P=[...v,"pointermove","scroll"];function y({doc:o=document,win:n=window,hostname:a=n.location.hostname,importLazy:s=t=>import(t),onInteraction:c=A}={}){let t=o.getElementById("pmw-chat");if(!t)return null;t.hidden=!1;let i=t.querySelector(".pmw-chat__teaser"),f=null,p=()=>f??(f=s(t.dataset.pmwLazy).then(e=>e.mountChat(t),e=>{throw f=null,e})),g=()=>p().catch(()=>{}),L=()=>p().then(e=>e.open(),()=>{}),d=()=>t.hasAttribute("data-open");c(o,g,{events:P});let m=x({doc:o,hostname:a});if(!m){let e=()=>{let r=new URLSearchParams(n.location.hash.slice(1)).get("pmw-chat");r==="panel"&&!d()?L():r==="bubble"&&f&&f.then(E=>E.close?.(),()=>{})};e(),n.addEventListener("hashchange",e)}let h=!1,b=(e=o.activeElement)=>{let r=h&&!d()&&!t.contains(e);t.toggleAttribute("inert",r)},T=o.querySelector("[data-pmw-chat-avoid]")??[...o.querySelectorAll("footer")].pop();m&&T&&(new n.IntersectionObserver(e=>{h=e.at(-1).isIntersecting,b()}).observe(T),t.addEventListener("focusout",e=>b(e.relatedTarget)));let S=n.sessionStorage,l=!1;try{l=!!S?.getItem(I)}catch{}let O=(e,r,E)=>e.addEventListener(r,_=>l||E(_),{passive:!0}),w=()=>{l=!0;try{S?.setItem(I,"1")}catch{}},u=()=>{l||h||!i||d()||(w(),i.hidden=!1,g())};return t.addEventListener("click",e=>{let r=e.target.closest?.("[data-pmw-chat-open],[data-pmw-chat-dismiss]");r&&(i&&(i.hidden=!0),w(),r.hasAttribute("data-pmw-chat-open")&&L())}),!l&&i&&m&&(n.setTimeout(u,C),O(n,"scroll",()=>{let e=o.documentElement.scrollHeight-n.innerHeight;e>0&&n.scrollY/e>=N&&u()}),n.matchMedia?.("(pointer: fine)").matches&&O(o,"mouseout",e=>{!e.relatedTarget&&e.clientY<=0&&u()})),{load:p,show:u}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>y()):y();})();
package/dist/widget.js CHANGED
@@ -1,2 +1,2 @@
1
- var Je=`.pmw-chat__panel{position:fixed;z-index:81;inset:0;display:flex;flex-direction:column;background:var(--pmw-chat-panel-bg, #ffffff);color:var(--pmw-chat-fg, #1f2328);font-family:var(--pmw-chat-font, inherit);font-size:16px;line-height:1.45}.pmw-chat__panel[hidden],.pmw-chat__panel [hidden]{display:none!important}@media(min-width:640px){.pmw-chat__panel{inset:auto;right:calc(env(safe-area-inset-right,0px) + var(--pmw-chat-right, 16px));bottom:calc(env(safe-area-inset-bottom,0px) + var(--pmw-chat-bottom, 16px));width:380px;height:min(600px,calc(100dvh - 16px - env(safe-area-inset-bottom,0px) - var(--pmw-chat-bottom, 16px)));border:1px solid var(--pmw-chat-line, #e6e9f0);border-radius:20px;overflow:hidden;box-shadow:0 18px 48px #1018282e}}.pmw-chat__panel :where(button){font:inherit;cursor:pointer}.pmw-chat__panel :focus-visible{outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}.pmw-chat__head{display:flex;align-items:center;gap:12px;padding:14px 10px 14px 16px;border-bottom:1px solid var(--pmw-chat-line, #e6e9f0)}@media(max-width:639.98px){.pmw-chat__head{padding:12px 8px 12px 16px}}.pmw-chat__face{position:relative;flex:none;display:flex}.pmw-chat__head .pmw-chat__avatar{width:44px;height:44px;box-shadow:none}.pmw-chat__head .pmw-chat__avatar--icon{box-shadow:0 0 0 1.5px var(--pmw-chat-avatar-ring, transparent)}.pmw-chat__head .pmw-chat__avatar img{width:44px;height:44px}.pmw-chat__head .pmw-chat__avatar--logo img{width:32px;height:32px}.pmw-chat__head .pmw-chat__avatar svg{width:100%;height:100%}.pmw-chat__dot{position:absolute;right:-1px;bottom:-1px;width:12px;height:12px;border-radius:50%;background:var(--pmw-chat-live, #1a7f37);box-shadow:0 0 0 2px var(--pmw-chat-panel-bg, #ffffff)}.pmw-chat__who{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.pmw-chat__name{margin:0;font-weight:600;font-size:16px}.pmw-chat__role,.pmw-chat__notice{margin:0;font-size:13px;line-height:1.4;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__panel[data-mode=human] .pmw-chat__role{color:var(--pmw-chat-live, #1a7f37);font-weight:500}.pmw-chat__notice a{color:var(--pmw-chat-link, #0a1b3d);text-decoration:underline;text-underline-offset:2px}.pmw-chat__close{flex:none;width:44px;height:44px;padding:0;border:0;border-radius:50%;display:flex;align-items:center;justify-content:center;background:transparent;color:var(--pmw-chat-fg, #1f2328)}.pmw-chat__close:hover{background:var(--pmw-chat-thread-bg, #f7f8fb)}.pmw-chat__close svg{width:18px;height:18px}.pmw-chat__body{flex:1;min-height:0;display:flex;flex-direction:column;overflow-y:auto;padding:18px 16px;background:var(--pmw-chat-thread-bg, #f7f8fb)}.pmw-chat__log{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}.pmw-chat__log>li{margin-top:4px}.pmw-chat__log>li:first-child,.pmw-chat__msg--bot+.pmw-chat__msg--bot,.pmw-chat__msg--bot+.pmw-chat__typing,.pmw-chat__msg--owner+.pmw-chat__msg--owner,.pmw-chat__msg--owner+.pmw-chat__typing,.pmw-chat__msg--visitor+.pmw-chat__msg--visitor{margin-top:0}.pmw-chat__msg{max-width:82%;padding:10px 14px;border-radius:18px;white-space:pre-wrap;overflow-wrap:anywhere}.pmw-chat__msg--bot,.pmw-chat__msg--owner{align-self:flex-start;border-bottom-left-radius:6px;color:var(--pmw-chat-fg, #1f2328)}.pmw-chat__msg--bot{background:var(--pmw-chat-other-bg, #ffffff);border:1px solid var(--pmw-chat-line, #e6e9f0)}.pmw-chat__msg--owner{background:var(--pmw-chat-owner-bg, #eef2fa);border:1px solid var(--pmw-chat-owner-border, #d5ddef)}.pmw-chat__msg--visitor{align-self:flex-end;background:var(--pmw-chat-accent, #0a1b3d);color:var(--pmw-chat-accent-fg, #ffffff);border-bottom-right-radius:6px}.pmw-chat__msg--bot+.pmw-chat__msg--bot,.pmw-chat__msg--owner+.pmw-chat__msg--owner{border-top-left-radius:6px}.pmw-chat__msg--visitor+.pmw-chat__msg--visitor{border-top-right-radius:6px}.pmw-chat__msg:has(>.pmw-chat__photo){padding:4px}.pmw-chat__msg:has(>.pmw-chat__photo) .pmw-chat__text:empty{display:none}.pmw-chat__msg.is-pending{opacity:.75}.pmw-chat__msg.is-failed{outline:2px solid var(--pmw-chat-error, #b42318);outline-offset:2px}.pmw-chat__retry{display:block;margin-top:4px;font-size:14px}.pmw-chat__retry button{padding:0;border:0;background:none;color:inherit;text-decoration:underline}.pmw-chat__typing{align-self:flex-start;display:flex;gap:5px;padding:14px 16px;border-radius:18px 18px 18px 6px;background:var(--pmw-chat-other-bg, #ffffff);border:1px solid var(--pmw-chat-line, #e6e9f0)}.pmw-chat__typing span{width:7px;height:7px;border-radius:50%;background:var(--pmw-chat-muted, #4b5563);animation:pmw-chat-dot 1.2s infinite ease-in-out}.pmw-chat__typing span:nth-child(2){animation-delay:.15s}.pmw-chat__typing span:nth-child(3){animation-delay:.3s}@keyframes pmw-chat-dot{0%,80%,to{opacity:.3}40%{opacity:1}}@media(prefers-reduced-motion:reduce){.pmw-chat__typing span{animation:none;opacity:.7}}.pmw-chat__log>.pmw-chat__relay{align-self:stretch;margin:18px 0 8px;display:flex;align-items:center;gap:10px;font-size:13px;font-weight:500;line-height:1.4;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__relay:before,.pmw-chat__relay:after{content:"";flex:1;min-width:16px;height:1px;background:var(--pmw-chat-rule, #cfd5e1)}.pmw-chat__relay-label{display:flex;align-items:center;gap:6px;text-align:center}.pmw-chat__relay-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--pmw-chat-live, #1a7f37)}.pmw-chat__log>.pmw-chat__relay+li{margin-top:0}.pmw-chat__state{margin:8px 0 0;font-size:14px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__state:empty{display:none}.pmw-chat__ended{margin-top:22px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center}.pmw-chat__ended:before{content:"";align-self:stretch;height:1px;background:var(--pmw-chat-line, #e6e9f0)}.pmw-chat__ended p{margin:0;font-size:15px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__restart{min-height:44px;padding:0 20px;border-radius:22px;border:1px solid var(--pmw-chat-link, #0a1b3d);background:var(--pmw-chat-panel-bg, #ffffff);color:var(--pmw-chat-link, #0a1b3d);font-weight:600}.pmw-chat__composer{display:flex;flex-direction:column;gap:6px;padding:12px 12px 10px;border-top:1px solid var(--pmw-chat-line, #e6e9f0);background:var(--pmw-chat-panel-bg, #ffffff)}@media(max-width:639.98px){.pmw-chat__composer{padding:10px 12px calc(14px + env(safe-area-inset-bottom,0px))}}.pmw-chat__box{display:flex;align-items:flex-end;gap:6px;padding:4px;border-radius:24px;border:1px solid var(--pmw-chat-field-border, #7d8590);background:#fff;color:var(--pmw-chat-field-fg, #1f2328)}.pmw-chat__box textarea{flex:1;min-width:0;box-sizing:border-box;min-height:44px;max-height:132px;resize:none;margin:0;padding:10px 2px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:16px;line-height:1.5}.pmw-chat__box textarea::placeholder{color:#6b7280;opacity:1}@supports selector(:has(*)){.pmw-chat__box textarea:focus-visible{outline:none}.pmw-chat__box:has(textarea:focus-visible){outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}}.pmw-chat__send{flex:none;min-height:44px;padding:0 18px;border:0;border-radius:22px;font-weight:600;background:var(--pmw-chat-accent, #0a1b3d);color:var(--pmw-chat-accent-fg, #ffffff)}.pmw-chat__send:disabled,.pmw-chat__box textarea:disabled{cursor:not-allowed;opacity:.6}.pmw-chat__composer .pmw-turnstile,.pmw-chat__fallback .pmw-turnstile{align-self:stretch}.pmw-chat__attach{flex:none;position:relative;width:44px;height:44px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;color:inherit}.pmw-chat__attach:hover{background:#eef0f4}.pmw-chat__attach svg{width:22px;height:22px}.pmw-chat__attach:focus-within{outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}.pmw-chat__hint{margin:0 8px;font-size:13px;line-height:1.4;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__composer .pmw-chat__photo-error{margin:0 8px}.pmw-chat__photo{display:block;max-width:100%;max-height:220px;border-radius:14px}.pmw-chat__fallback{display:flex;flex-direction:column;gap:14px;margin-top:16px}.pmw-chat__fb-title{margin:0;font-size:20px;font-weight:600;line-height:1.3}.pmw-chat__fb-intro{margin:-8px 0 0;font-size:15px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__field{display:flex;flex-direction:column;gap:4px}.pmw-chat__field label{font-size:14px;font-weight:600}.pmw-chat__field input,.pmw-chat__field textarea{font:inherit;font-size:16px;padding:10px 14px;border-radius:12px;resize:none;border:1px solid var(--pmw-chat-field-border, #7d8590);background:#fff;color:var(--pmw-chat-field-fg, #1f2328)}.pmw-chat__fallback .pmw-chat__send{align-self:stretch;min-height:48px;border-radius:24px}.pmw-chat__error{margin:0;font-size:14px;color:var(--pmw-chat-error, #b42318)}.pmw-chat__error:empty{display:none}.pmw-chat__sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.pmw-chat[data-unread] .pmw-chat__bubble{position:relative}.pmw-chat[data-unread] .pmw-chat__bubble:after{content:"";position:absolute;top:2px;left:38px;width:12px;height:12px;border-radius:50%;background:var(--pmw-chat-error, #b42318);box-shadow:0 0 0 2px #fff}
2
- `;var je=new Set;function Ge(s,{doc:l=document,attrs:r={}}={}){if(je.has(s)||(je.add(s),l.querySelector(`script[src="${s}"]`)))return;let t=l.createElement("script");t.src=s,t.async=!0;for(let[d,m]of Object.entries(r))t.setAttribute(d,m);l.head.append(t)}function Ve({doc:s=document,hostname:l=globalThis.location?.hostname??""}={}){let r=s.getElementById("pmw-hosts");if(!r)return!1;let t;try{t=JSON.parse(r.textContent??"")}catch{return!1}if(!Array.isArray(t))return!1;let d=String(l).toLowerCase();return t.some(m=>typeof m=="string"&&m.toLowerCase()===d)}var mt="https://challenges.cloudflare.com/turnstile/v0/api.js";var ut=15e3,Ke=200;function fe(s=document){Ge(mt,{doc:s})}var ft=s=>s.querySelector('input[name="cf-turnstile-response"]')?.value??"",wt=s=>{let l=s.querySelector(".cf-turnstile");return!!l&&l.offsetHeight>0};async function Xe(s,{win:l=window,timeoutMs:r=ut}={}){typeof s.pmwTurnstileLoad=="function"?s.pmwTurnstileLoad():fe(l.document);let t=0;for(;ft(s)==="";){if(!wt(s)){if(t>=r)return!1;t+=Ke}await new Promise(d=>l.setTimeout(d,Ke))}return!0}function Ye(s,{win:l=window}={}){let r=s.querySelector(".cf-turnstile");if(!r||r.offsetHeight>0)return!1;try{return l.turnstile?.reset(r),!0}catch{return!1}}var Qe={unauthorized:4401,expired:4410,rpc:4426,deleted:4404};var _t={fr:{assistant:"Assistant virtuel",openLabel:"Assistant virtuel, ouvrir le clavardage",dismissTeaser:"Masquer ce message",close:"Fermer le clavardage",panelTitle:"Clavardage",automated:"R\xE9ponses automatiques, tir\xE9es du contenu de ce site.",privacy:"Confidentialit\xE9",you:"Vous",messageLabel:"Votre message",send:"Envoyer",live:"Vous r\xE9pond en direct",relay:"{name} a pris le relais",relayGeneric:"Une personne de l\u2019\xE9quipe a pris le relais",connecting:"Connexion\u2026",reconnecting:"Connexion perdue, reconnexion\u2026",notSent:"Non envoy\xE9.",retry:"R\xE9essayer",ended:"Cette conversation est termin\xE9e.",newConversation:"Nouvelle conversation",reload:"Rechargez la page pour continuer.",unavailable:"Un probl\xE8me est survenu. R\xE9essayez dans un moment.",turnstile:"L\u2019envoi n\u2019a pas abouti. R\xE9essayez.",tooMany:"Un instant, puis r\xE9essayez.",fallbackTitle:"Laissez-nous vos coordonn\xE9es",fallbackCap:"Laissez vos coordonn\xE9es et nous vous recontacterons.",fallbackDown:"Laissez vos coordonn\xE9es et nous vous recontacterons.",name:"Votre nom",contact:"T\xE9l\xE9phone ou courriel",need:"Votre demande",fallbackSend:"Envoyer la demande",fallbackSent:"Merci, votre demande a \xE9t\xE9 transmise.",fallbackMissing:"Remplissez les trois champs.",said:"{name}\xA0: {text}",attachPhoto:"Joindre une photo",photoHint:"\xC9vitez les photos de personnes ou de pi\xE8ces d\u2019identit\xE9.",photoAlt:"Votre photo",photoSent:"Photo envoy\xE9e",photoLimit:"Trois photos au plus par conversation.",photoTooLarge:"Cette photo est trop lourde.",photoUnreadable:"Ce fichier n\u2019est pas une photo lisible.",photoFailed:"La photo n\u2019a pas pu \xEAtre envoy\xE9e. R\xE9essayez.",demoAsk:"Pouvez-vous m\u2019en dire plus sur {topic}?",demoQuestion:"Quels sont vos d\xE9lais?",demoAnswer:"(D\xE9monstration) Ici, l\u2019assistant r\xE9pond \xE0 partir du contenu de votre site.",demoHint:"Aper\xE7u\xA0: la conversation est d\xE9sactiv\xE9e."},en:{assistant:"Virtual assistant",openLabel:"Virtual assistant, open the chat",dismissTeaser:"Hide this message",close:"Close the chat",panelTitle:"Chat",automated:"Automatic answers, drawn from this site\u2019s content.",privacy:"Privacy",you:"You",messageLabel:"Your message",send:"Send",live:"Replying live",relay:"{name} took over",relayGeneric:"A team member took over",connecting:"Connecting\u2026",reconnecting:"Connection lost, reconnecting\u2026",notSent:"Not sent.",retry:"Retry",ended:"This conversation has ended.",newConversation:"New conversation",reload:"Reload the page to continue.",unavailable:"Something went wrong. Try again in a moment.",turnstile:"Sending did not go through. Try again.",tooMany:"One moment, then try again.",fallbackTitle:"Leave us your details",fallbackCap:"Leave your details and we will get back to you.",fallbackDown:"Leave your details and we will get back to you.",name:"Your name",contact:"Phone or email",need:"Your request",fallbackSend:"Send the request",fallbackSent:"Thank you, your request was sent.",fallbackMissing:"Fill in all three fields.",said:"{name}: {text}",attachPhoto:"Attach a photo",photoHint:"Avoid photos of people or of identity documents.",photoAlt:"Your photo",photoSent:"Photo sent",photoLimit:"Three photos at most per conversation.",photoTooLarge:"This photo is too large.",photoUnreadable:"This file is not a readable photo.",photoFailed:"The photo could not be sent. Try again.",demoAsk:"Can you tell me more about {topic}?",demoQuestion:"What are your lead times?",demoAnswer:"(Demo) Here, the assistant answers from your site\u2019s content.",demoHint:"Preview: the conversation is turned off."}},Z=s=>String(s??"").slice(0,2).toLowerCase()==="en"?"en":"fr";function we(s,l={}){let r=_t[Z(s)],t={...r};for(let[d,m]of Object.entries(l??{}))typeof m=="string"&&d in r&&(t[d]=m);return t}var H=(s,l)=>s.replace(/\{(\w+)\}/g,(r,t)=>t in l?String(l[t]):r);var _e="pmw-chat",Ze=[1e3,2e3,4e3,8e3,16e3,3e4],et=3,gt="chat-lead",xt=3,bt=1600,vt=.85,yt=3e3,kt=8e3,St='<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 8"/></svg>';async function Tt(s,l){let r=await s.createImageBitmap(l,{imageOrientation:"from-image"}),t=Math.min(1,bt/Math.max(r.width,r.height)),d=Math.max(1,Math.round(r.width*t)),m=Math.max(1,Math.round(r.height*t)),u=s.document.createElement("canvas");return u.width=d,u.height=m,u.getContext("2d").drawImage(r,0,0,d,m),r.close?.(),new Promise((U,ee)=>u.toBlob(D=>D?U(D):ee(new Error("encode")),"image/jpeg",vt))}var Lt={fr:{nos:"vos",notre:"votre",nous:"vous"},en:{our:"your",us:"you",we:"you"}},Ct={fr:/^une question sur (.+?)\s*\?$/i,en:/^(?:a |any )?questions? (?:about|on) (.+?)\s*\?$/i};function Et(s,l){let r=Z(l),t=we(r),d=String(s??"").trim().match(Ct[r]);if(!d)return t.demoQuestion;let m=d[1].replace(/[\p{L}’']+/gu,u=>Lt[r][u.toLowerCase()]??u);return H(t.demoAsk,{topic:m})}var Ot='<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>';function o(s,l,r={},t){let d=s.createElement(l);for(let[m,u]of Object.entries(r))u!=null&&u!==!1&&d.setAttribute(m,u===!0?"":u);return t!==void 0&&(d.textContent=t),d}var At=s=>({load:()=>fe(s.document),async token(l){return await Xe(l,{win:s}),l.querySelector('input[name="cf-turnstile-response"]')?.value??""},reset:l=>Ye(l,{win:s})});function Jt(s,l={}){if(s.pmwChat)return s.pmwChat;let r=l.win??window,t=r.document,d=l.WebSocket??r.WebSocket,m=l.fetch??((...e)=>r.fetch(...e)),u=l.turnstile??At(r),U=l.uuid??(()=>r.crypto.randomUUID()),ee=l.shrink??(e=>Tt(r,e)),D=l.preview??(e=>new Promise((n,a)=>{let c=new r.FileReader;c.onload=()=>n(String(c.result)),c.onerror=()=>a(c.error),c.readAsDataURL(e)})),S={};try{S=JSON.parse(s.querySelector("[data-pmw-chat-config]")?.textContent??"{}")}catch{}let B=Z(S.lang),i=we(B,S.strings),ge=s.dataset.pmwChatOpener??"",tt=s.querySelector(".pmw-chat__bubble"),f=l.demo??(S.demo===!0||!Ve({doc:t,hostname:r.location.hostname}));t.getElementById("pmw-chat-style")||t.head.append(o(t,"style",{id:"pmw-chat-style"},Je));let k=o(t,"div",{class:"pmw-chat__panel",role:"dialog","aria-modal":"true","aria-labelledby":"pmw-chat-name",hidden:!0}),xe=o(t,"div",{class:"pmw-chat__head"}),te=s.querySelector(".pmw-chat__avatar")?.cloneNode(!0)??o(t,"span",{class:"pmw-chat__avatar","aria-hidden":"true"});for(let e of te.querySelectorAll("[id]")){let n=e.id;e.id=`${n}-head`;for(let a of te.querySelectorAll(`[mask="url(#${n})"]`))a.setAttribute("mask",`url(#${n}-head)`)}let be=o(t,"div",{class:"pmw-chat__who"}),ve=o(t,"p",{class:"pmw-chat__name",id:"pmw-chat-name"}),$=o(t,"p",{class:"pmw-chat__role"}),ye=o(t,"span",{class:"pmw-chat__face"}),ke=o(t,"span",{class:"pmw-chat__dot","aria-hidden":"true",hidden:!0}),F=o(t,"button",{type:"button",class:"pmw-chat__close","aria-label":i.close});F.innerHTML=Ot;let J=o(t,"p",{class:"pmw-chat__notice"},i.automated);S.privacyHref&&(J.append(" "),J.append(o(t,"a",{href:S.privacyHref},i.privacy))),ye.append(te,ke),be.append(ve,$,J),xe.append(ye,be,F);let w=o(t,"ol",{class:"pmw-chat__log"}),at=o(t,"li",{class:"pmw-chat__msg pmw-chat__msg--bot pmw-chat__msg--greeting"},ge),y=o(t,"li",{class:"pmw-chat__typing","aria-hidden":"true",hidden:!0});y.append(o(t,"span"),o(t,"span"),o(t,"span")),w.append(at);let ae=o(t,"p",{class:"pmw-chat__state",role:"status"}),Se=o(t,"p",{class:"pmw-chat__sr","aria-live":"polite"}),Te=e=>o(t,"div",{class:"cf-turnstile pmw-turnstile","data-sitekey":S.siteKey??"","data-action":"chat","data-theme":"light","data-language":B,"data-appearance":"interaction-only","data-size":"flexible"}),_=o(t,"form",{class:"pmw-chat__composer",novalidate:!0}),g=o(t,"textarea",{id:"pmw-chat-input",name:"message",rows:"1",maxlength:String(2e3),placeholder:i.messageLabel,autocomplete:"off",disabled:f,"aria-describedby":f?"pmw-chat-demo-hint":null}),nt=o(t,"button",{type:"submit",class:"pmw-chat__send",disabled:f},i.send),ne=o(t,"div",{class:"pmw-chat__box"});ne.append(o(t,"label",{class:"pmw-chat__sr",for:"pmw-chat-input"},i.messageLabel),g,nt),_.append(ne),f?_.append(o(t,"p",{class:"pmw-chat__hint",id:"pmw-chat-demo-hint"},i.demoHint)):_.append(Te(_));let q=null,O=o(t,"p",{class:"pmw-chat__error pmw-chat__photo-error",role:"alert"});if(S.photos===!0){let e=o(t,"label",{class:"pmw-chat__attach"});q=o(t,"input",{type:"file",class:"pmw-chat__sr",accept:"image/*",multiple:!0,"aria-describedby":"pmw-chat-photo-hint",disabled:f}),e.innerHTML=St,e.prepend(q),e.append(o(t,"span",{class:"pmw-chat__sr"},i.attachPhoto)),ne.insertBefore(e,g),_.append(o(t,"p",{class:"pmw-chat__hint",id:"pmw-chat-photo-hint"},i.photoHint),O)}let j=o(t,"div",{class:"pmw-chat__ended",hidden:!0}),Le=o(t,"p",{},i.ended),G=o(t,"button",{type:"button",class:"pmw-chat__restart"},i.newConversation);j.append(Le,G);let x=o(t,"form",{class:"pmw-chat__fallback",novalidate:!0,hidden:!0,"aria-labelledby":"pmw-chat-fb-title"}),Ce=o(t,"p",{class:"pmw-chat__fb-intro"}),oe=(e,n,a)=>{let c=`pmw-chat-fb-${e}`,p=o(t,"div",{class:"pmw-chat__field"});return p.append(o(t,"label",{for:c},i[e]),o(t,n,{id:c,name:e,required:!0,...a})),p},V=o(t,"p",{class:"pmw-chat__error",role:"alert"});x.append(o(t,"p",{class:"pmw-chat__fb-title",id:"pmw-chat-fb-title"},i.fallbackTitle),Ce,oe("name","input",{autocomplete:"name",maxlength:"100"}),oe("contact","input",{autocomplete:"on",maxlength:"200"}),oe("need","textarea",{rows:"3",maxlength:"2000"}),V,o(t,"button",{type:"submit",class:"pmw-chat__send"},i.fallbackSend)),f||x.append(Te(x));let Ee=o(t,"div",{class:"pmw-chat__body"});Ee.append(w,ae,j,x),k.append(xe,Ee,_,Se),s.append(k);let h=null;if(!f)try{h=JSON.parse(r.sessionStorage.getItem(_e)??"null")}catch{h=null}let Oe=()=>{try{r.sessionStorage.setItem(_e,JSON.stringify(h))}catch{}},b=null,re=0,se=null,R=null,K=0,I="bot",T=null,A=!1,Ae=0,ie=null,ce=new Set,L=new Map,X=new Set,le=0,Ne=()=>new URLSearchParams(r.location.hash.slice(1)).get("pmw-chat-botName")?.trim()||null,Me=f?Ne():null,pe=()=>f?Me:h?.botName||null;function z(){let e=I==="human";ve.textContent=e?T||i.assistant:pe()||i.assistant,$.textContent=e?i.live:pe()?i.assistant:"",$.hidden=$.textContent==="",J.hidden=e,ke.hidden=!e,k.dataset.mode=I}z();let C=e=>{ae.textContent=e??"",ae.hidden=!e};C("");let Pe=e=>e.role==="visitor"?i.you:e.role==="owner"?e.by||T||i.assistant:pe()||i.assistant;function N(e,n){let a=o(t,"li",{class:`pmw-chat__msg pmw-chat__msg--${n}`,"data-id":e}),c=o(t,"span",{class:"pmw-chat__sr"}),p=o(t,"span",{class:"pmw-chat__text"});return a.append(c,p),w.insertBefore(a,y.parentNode===w?y:null),a}function qe(e,n){let a=e?e.previousElementSibling:y.parentNode===w?y.previousElementSibling:w.lastElementChild;for(;a;a=a.previousElementSibling){if(a.classList.contains("pmw-chat__relay")||a.classList.contains("pmw-chat__msg--owner"))return;if(a.classList.contains("pmw-chat__msg--bot"))break}let c=n?H(i.relay,{name:n}):i.relayGeneric,p=o(t,"li",{class:"pmw-chat__relay",role:"separator","aria-label":c}),v=o(t,"span",{class:"pmw-chat__relay-label","aria-hidden":"true"});v.append(o(t,"span",{class:"pmw-chat__relay-dot"}),c),p.append(v),w.insertBefore(p,e??(y.parentNode===w?y:null))}let Re=e=>[...w.querySelectorAll("li[data-id]")].find(n=>n.dataset.id===e),E=(e,n,a)=>{e.firstChild.textContent=H(i.said,{name:Pe(n),text:""}),e.lastChild.textContent=a===""&&n.photos?.length&&!e.querySelector("img")?i.photoSent:a};function de(){y.hidden=!0,r.clearTimeout(ie)}function Ie(e,{announce:n}){if(ce.has(e.seq))return;ce.add(e.seq),K=Math.max(K,e.seq);let a=Re(e.id);a||(a=N(e.id,e.role),e.role==="owner"&&qe(a,e.by||T)),a.classList.remove("is-streaming","is-pending","is-failed"),a.querySelector(".pmw-chat__retry")?.remove(),E(a,e,e.text),e.role==="visitor"?(L.delete(e.id),e.photos?.length&&X.add(e.id)):(de(),n&&(Se.textContent=H(i.said,{name:Pe(e),text:e.text})),k.hidden&&s.setAttribute("data-unread","")),M()}function ot(e,n){de();let a=Re(e);a||(a=N(e,"bot"),a.classList.add("is-streaming"),E(a,{role:"bot"},"")),a.lastChild.textContent+=n,M()}let M=()=>{w.scrollTop=w.scrollHeight};if(f){E(N("demo-question","visitor"),{role:"visitor"},Et(ge,B));let e=N("demo-answer","bot");E(e,{role:"bot"},i.demoAnswer),r.addEventListener("hashchange",()=>{Me=Ne(),z(),E(e,{role:"bot"},i.demoAnswer)})}function Y(e){if(e.failed=!0,e.li.classList.remove("is-pending"),e.li.classList.add("is-failed"),e.li.querySelector(".pmw-chat__retry"))return;let n=o(t,"span",{class:"pmw-chat__retry"},`${i.notSent} `),a=o(t,"button",{type:"button"},i.retry);a.addEventListener("click",()=>{e.failed=!1,e.tries=0,n.remove(),e.li.classList.replace("is-failed","is-pending"),h?Q()?he(e):P():me()}),n.append(a),e.li.append(n)}let Q=()=>!!b&&b.readyState===1;function he(e){if(!(e.failed||!Q())){if(e.tries>=et)return Y(e);e.tries++,b.send(JSON.stringify({type:"message",id:e.id,text:e.text}))}}function P(){if(!h||A||b)return;r.clearTimeout(se);let e=r.location.protocol==="https:"?"wss:":"ws:",n=new URLSearchParams({c:h.c,k:h.k}),a=new d(`${e}//${r.location.host}/api/chat/ws?${n}`);b=a,a.onopen=()=>{re=0,C(""),a.send(JSON.stringify({type:"hello",since:K}));for(let c of L.values())he(c)},a.onmessage=c=>{let p;try{p=JSON.parse(c.data)}catch{return}st(p)},a.onclose=c=>{b===a&&(b=null);for(let v of L.values())v.tries>=et&&Y(v);if(c.code>=4e3&&c.code<5e3)return rt(c.code);if(A)return;C(i.reconnecting);let p=Ze[Math.min(re,Ze.length-1)];re++,se=r.setTimeout(P,p)}}function rt(e){if(e===Qe.rpc)return W(i.reload,!1);W(i.ended,!0)}function W(e,n){A=!0,r.clearTimeout(se),C(""),Le.textContent=e,G.hidden=!n,j.hidden=!1,_.hidden=!0;for(let a of L.values())Y(a)}function ze(e){I=e.mode==="human"?"human":"bot",T=typeof e.ownerName=="string"?e.ownerName:null,z(),I==="human"&&qe(null,T),e.closed&&W(i.ended,!0)}function st(e){switch(e?.type){case"history":typeof e.ownerName=="string"&&(T=e.ownerName);for(let n of e.messages??[])Ie(n,{announce:!1});return ze(e);case"message":return Ie(e.msg,{announce:e.msg?.role!=="visitor"});case"delta":return ot(e.id,e.text);case"typing":return e.role==="visitor"?void 0:(y.hidden=!1,w.append(y),r.clearTimeout(ie),ie=r.setTimeout(de,kt),M());case"status":return ze(e);case"fallback":return Be(e.reason==="cap"?i.fallbackCap:i.fallbackDown);case"lead":return Ue();case"error":(e.code==="closed"||e.code==="deleted")&&W(i.ended,!0)}}let He=!1;function Ue(){if(!(He||h?.lead)){He=!0,h&&(h.lead=!0,Oe());try{r.pmw?.event(gt)}catch{}}}async function De(e,n){let a="";try{a=await u.token(e)}catch{a=""}let c,p=null;try{c=await m("/api/chat/start",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({token:a,lang:B,page:r.location.pathname,...n})}),p=await c.json()}catch{c=null}try{u.reset(e)}catch{}return c?.ok&&p?{ok:!0,data:p}:{ok:!1,error:c?.status===403?i.turnstile:c?.status===429?i.tooMany:null,cap:c?.status===409&&p?.error==="cap"}}function me(){return R||h||(C(i.connecting),R=De(_,{}).then(e=>{if(R=null,e.ok&&e.data.conversationId&&e.data.visitorKey){h={c:e.data.conversationId,k:e.data.visitorKey,botName:typeof e.data.botName=="string"?e.data.botName:null},Oe(),z(),P();return}for(let n of L.values())Y(n);if(e.error)return C(e.error);C(""),Be(e.cap?i.fallbackCap:i.fallbackDown)})),R}function Be(e){Ce.textContent=e,x.hidden=!1,_.hidden=!0,x.querySelector("input")?.focus()}x.addEventListener("submit",async e=>{e.preventDefault();let n=p=>x.elements[p].value.trim(),a={name:n("name"),contact:n("contact"),need:n("need")};if(!a.name||!a.contact||!a.need){V.textContent=i.fallbackMissing;return}V.textContent="";let c=await De(x,{fallbackLead:a});if(!c.ok){V.textContent=c.error??i.unavailable;return}x.replaceChildren(o(t,"p",{class:"pmw-chat__fb-done",role:"status"},i.fallbackSent)),Ue()});let it={413:"photoTooLarge",415:"photoUnreadable",409:"photoLimit",429:"tooMany"};async function ct(e){let n,a;try{n=await ee(e),a=await D(n)}catch{O.textContent=i.photoUnreadable;return}let c=U(),p=N(c,"visitor");p.classList.add("is-pending"),p.insertBefore(o(t,"img",{class:"pmw-chat__photo",src:a,alt:i.photoAlt}),p.lastChild),E(p,{role:"visitor"},""),M(),h||await me();let v=null;if(h){let dt=new URLSearchParams({c:h.c,k:h.k,id:c});try{v=await m(`/api/chat/photo?${dt}`,{method:"POST",headers:{"content-type":"image/jpeg"},body:n})}catch{v=null}}if(v?.ok){p.classList.remove("is-pending"),X.add(c),b||P();return}p.remove(),O.textContent=i[it[v?.status]]??i.photoFailed}q?.addEventListener("change",async()=>{let e=[...q.files??[]];if(q.value="",O.textContent="",A||f||e.length===0)return;let n=xt-X.size-le;e.length>n&&(O.textContent=i.photoLimit);let a=e.slice(0,Math.max(0,n));le+=a.length;for(let c of a)try{await ct(c)}finally{le--}});function $e(){let e=g.value.trim();if(!e||A||f)return;g.value="",Fe();let n={id:U(),text:e.slice(0,2e3),tries:0,failed:!1};n.li=N(n.id,"visitor"),n.li.classList.add("is-pending"),E(n.li,{role:"visitor"},n.text),L.set(n.id,n),M(),h?Q()?he(n):P():me()}_.addEventListener("submit",e=>{e.preventDefault(),$e()}),g.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&!e.isComposing&&(e.preventDefault(),$e())});let Fe=()=>{g.style.height="auto",g.style.height=g.value?`${Math.min(g.scrollHeight,132)}px`:""};g.addEventListener("input",()=>{Fe();let e=Date.now();Q()&&e-Ae>yt&&(Ae=e,b.send(JSON.stringify({type:"typing"})))}),G.addEventListener("click",()=>{h=null;try{r.sessionStorage.removeItem(_e)}catch{}try{b?.close(1e3)}catch{}b=null,A=!1,K=0,ce.clear(),L.clear(),X.clear(),O.textContent="",I="bot",T=null,z();for(let e of[...w.querySelectorAll("li[data-id], .pmw-chat__relay")])e.remove();j.hidden=!0,_.hidden=!1,g.focus()});let lt=()=>[...k.querySelectorAll('a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])')].filter(e=>!e.disabled&&!e.closest("[hidden]"));k.addEventListener("keydown",e=>{if(e.key==="Escape"){e.preventDefault(),ue();return}if(e.key!=="Tab")return;let n=lt();if(n.length===0)return;let a=n[0],c=n[n.length-1];e.shiftKey&&t.activeElement===a?(e.preventDefault(),c.focus()):!e.shiftKey&&t.activeElement===c&&(e.preventDefault(),a.focus())}),F.addEventListener("click",()=>ue());function pt(){f||u.load(),k.hidden=!1,s.setAttribute("data-open",""),s.removeAttribute("data-unread"),h&&P(),(f?F:_.hidden?x.hidden?G:x.querySelector("input"):g)?.focus(),M()}function ue(){k.hidden=!0,s.removeAttribute("data-open"),tt?.focus()}return s.pmwChat={open:pt,close:ue,panel:k},s.pmwChat}export{Ze as BACKOFF_MS,gt as GOAL,xt as MAX_PHOTOS,et as MAX_TRIES,bt as PHOTO_EDGE,vt as PHOTO_QUALITY,_e as SESSION_KEY,Et as demoQuestion,Jt as mountChat};
1
+ var it=`.pmw-chat__panel{position:fixed;z-index:81;inset:0;display:flex;flex-direction:column;background:var(--pmw-chat-panel-bg, #ffffff);color:var(--pmw-chat-fg, #1f2328);font-family:var(--pmw-chat-font, inherit);font-size:16px;line-height:1.45}.pmw-chat__panel[hidden]{display:flex!important;opacity:0;pointer-events:none}.pmw-chat__panel [hidden]{display:none!important}@media(min-width:640px){.pmw-chat__panel{inset:auto;right:calc(env(safe-area-inset-right,0px) + var(--pmw-chat-right, 16px));bottom:calc(env(safe-area-inset-bottom,0px) + var(--pmw-chat-bottom, 16px));width:380px;height:min(600px,calc(100dvh - 16px - env(safe-area-inset-bottom,0px) - var(--pmw-chat-bottom, 16px)));border:1px solid var(--pmw-chat-line, #e6e9f0);border-radius:20px;overflow:hidden;box-shadow:0 18px 48px #1018282e}}.pmw-chat__panel :where(button){font:inherit;cursor:pointer}.pmw-chat__panel :focus-visible{outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}.pmw-chat__head{display:flex;align-items:center;gap:12px;padding:14px 10px 14px 16px;border-bottom:1px solid var(--pmw-chat-line, #e6e9f0)}@media(max-width:639.98px){.pmw-chat__head{padding:12px 8px 12px 16px}}.pmw-chat__face{position:relative;flex:none;display:flex}.pmw-chat__head .pmw-chat__avatar{width:44px;height:44px;box-shadow:none}.pmw-chat__head .pmw-chat__avatar--icon{box-shadow:0 0 0 1.5px var(--pmw-chat-avatar-ring, transparent)}.pmw-chat__head .pmw-chat__avatar img{width:44px;height:44px}.pmw-chat__head .pmw-chat__avatar--logo img{width:32px;height:32px}.pmw-chat__head .pmw-chat__avatar svg{width:100%;height:100%}.pmw-chat__dot{position:absolute;right:-1px;bottom:-1px;width:12px;height:12px;border-radius:50%;background:var(--pmw-chat-live, #1a7f37);box-shadow:0 0 0 2px var(--pmw-chat-panel-bg, #ffffff)}.pmw-chat__who{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.pmw-chat__name{margin:0;font-weight:600;font-size:16px}.pmw-chat__role,.pmw-chat__notice{margin:0;font-size:13px;line-height:1.4;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__panel[data-mode=human] .pmw-chat__role{color:var(--pmw-chat-live, #1a7f37);font-weight:500}.pmw-chat__notice a{color:var(--pmw-chat-link, #0a1b3d);text-decoration:underline;text-underline-offset:2px}.pmw-chat__close{flex:none;width:44px;height:44px;padding:0;border:0;border-radius:50%;display:flex;align-items:center;justify-content:center;background:transparent;color:var(--pmw-chat-fg, #1f2328)}.pmw-chat__close:hover{background:var(--pmw-chat-thread-bg, #f7f8fb)}.pmw-chat__close svg{width:18px;height:18px}.pmw-chat__body{flex:1;min-height:0;display:flex;flex-direction:column;overflow-y:auto;padding:18px 16px;background:var(--pmw-chat-thread-bg, #f7f8fb)}.pmw-chat__log{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}.pmw-chat__log>li{margin-top:4px}.pmw-chat__log>li:first-child,.pmw-chat__msg--bot+.pmw-chat__msg--bot,.pmw-chat__msg--bot+.pmw-chat__typing,.pmw-chat__msg--owner+.pmw-chat__msg--owner,.pmw-chat__msg--owner+.pmw-chat__typing,.pmw-chat__msg--visitor+.pmw-chat__msg--visitor{margin-top:0}.pmw-chat__msg{max-width:82%;padding:10px 14px;border-radius:18px;white-space:pre-wrap;overflow-wrap:anywhere}.pmw-chat__msg--bot,.pmw-chat__msg--owner{align-self:flex-start;border-bottom-left-radius:6px;color:var(--pmw-chat-fg, #1f2328)}.pmw-chat__msg--bot{background:var(--pmw-chat-other-bg, #ffffff);border:1px solid var(--pmw-chat-line, #e6e9f0)}.pmw-chat__msg--owner{background:var(--pmw-chat-owner-bg, #eef2fa);border:1px solid var(--pmw-chat-owner-border, #d5ddef)}.pmw-chat__msg--visitor{align-self:flex-end;background:var(--pmw-chat-accent, #0a1b3d);color:var(--pmw-chat-accent-fg, #ffffff);border-bottom-right-radius:6px}.pmw-chat__msg--bot+.pmw-chat__msg--bot,.pmw-chat__msg--owner+.pmw-chat__msg--owner{border-top-left-radius:6px}.pmw-chat__msg--visitor+.pmw-chat__msg--visitor{border-top-right-radius:6px}.pmw-chat__msg:has(>.pmw-chat__photo){padding:4px}.pmw-chat__msg:has(>.pmw-chat__photo) .pmw-chat__text:empty{display:none}.pmw-chat__msg.is-pending{opacity:.75}.pmw-chat__msg.is-failed{outline:2px solid var(--pmw-chat-error, #b42318);outline-offset:2px}.pmw-chat__retry{display:block;margin-top:4px;font-size:14px}.pmw-chat__retry button{padding:0;border:0;background:none;color:inherit;text-decoration:underline}.pmw-chat__typing{align-self:flex-start;display:flex;gap:5px;padding:14px 16px;border-radius:18px 18px 18px 6px;background:var(--pmw-chat-other-bg, #ffffff);border:1px solid var(--pmw-chat-line, #e6e9f0)}.pmw-chat__typing span{width:7px;height:7px;border-radius:50%;background:var(--pmw-chat-muted, #4b5563);animation:pmw-chat-dot 1.2s infinite ease-in-out}.pmw-chat__typing span:nth-child(2){animation-delay:.15s}.pmw-chat__typing span:nth-child(3){animation-delay:.3s}@keyframes pmw-chat-dot{0%,80%,to{opacity:.3}40%{opacity:1}}@media(prefers-reduced-motion:reduce){.pmw-chat__typing span{animation:none;opacity:.7}}.pmw-chat__log>.pmw-chat__relay,.pmw-chat__log>.pmw-chat__handback{align-self:stretch;margin:18px 0 8px;display:flex;align-items:center;gap:10px;font-size:13px;font-weight:500;line-height:1.4;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__relay:before,.pmw-chat__relay:after,.pmw-chat__handback:before,.pmw-chat__handback:after{content:"";flex:1;min-width:16px;height:1px;background:var(--pmw-chat-rule, #cfd5e1)}.pmw-chat__relay-label{display:flex;align-items:center;gap:6px;text-align:center}.pmw-chat__relay-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--pmw-chat-live, #1a7f37)}.pmw-chat__relay-dot--back{background:var(--pmw-chat-muted, #4b5563)}.pmw-chat__log>.pmw-chat__relay+li,.pmw-chat__log>.pmw-chat__handback+li{margin-top:0}.pmw-chat__state{margin:8px 0 0;font-size:14px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__state:empty{display:none}.pmw-chat__ended{margin-top:22px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center}.pmw-chat__ended:before{content:"";align-self:stretch;height:1px;background:var(--pmw-chat-line, #e6e9f0)}.pmw-chat__ended p{margin:0;font-size:15px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__restart{min-height:44px;padding:0 20px;border-radius:22px;border:1px solid var(--pmw-chat-link, #0a1b3d);background:var(--pmw-chat-panel-bg, #ffffff);color:var(--pmw-chat-link, #0a1b3d);font-weight:600}.pmw-chat__composer{--pmw-chat-gap: 8px;display:flex;flex-direction:column;gap:var(--pmw-chat-gap);padding:14px 16px 16px;border-top:1px solid var(--pmw-chat-line, #e6e9f0);background:var(--pmw-chat-panel-bg, #ffffff)}@media(max-width:639.98px){.pmw-chat__composer{padding:12px 16px calc(14px + env(safe-area-inset-bottom,0px))}}.pmw-chat__box{display:flex;align-items:flex-end;gap:6px;padding:4px;border-radius:24px;border:1px solid var(--pmw-chat-field-border, #7d8590);background:#fff;color:var(--pmw-chat-field-fg, #1f2328)}.pmw-chat__box textarea{flex:1;min-width:0;box-sizing:border-box;min-height:44px;max-height:132px;resize:none;margin:0;padding:10px 2px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:16px;line-height:1.5}.pmw-chat__box textarea::placeholder{color:#6b7280;opacity:1}@supports selector(:has(*)){.pmw-chat__box textarea:focus-visible{outline:none}.pmw-chat__box:has(textarea:focus-visible){outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}}.pmw-chat__send{flex:none;min-height:44px;padding:0 18px;border:0;border-radius:22px;font-weight:600;background:var(--pmw-chat-accent, #0a1b3d);color:var(--pmw-chat-accent-fg, #ffffff)}.pmw-chat__send:disabled,.pmw-chat__box textarea:disabled{cursor:not-allowed;opacity:.6}.pmw-chat__check{position:relative;display:grid;grid-template-rows:0fr;margin-bottom:calc(-1 * var(--pmw-chat-gap, 0px));transition:grid-template-rows .25s ease,margin-bottom .25s ease}.pmw-chat__check[data-ask]{grid-template-rows:1fr;margin-bottom:4px}.pmw-chat__check-in{min-height:0;overflow:hidden}.pmw-chat__check:not([data-ask]) .pmw-chat__check-in{position:absolute;top:0;left:0;right:0;overflow:visible;opacity:0;pointer-events:none}@media(prefers-reduced-motion:reduce){.pmw-chat__check{transition:none}}.pmw-chat__attach{flex:none;position:relative;width:44px;height:44px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;color:inherit}.pmw-chat__attach:hover{background:#eef0f4}.pmw-chat__attach svg{width:22px;height:22px}.pmw-chat__attach:focus-within{outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}.pmw-chat__hint{margin:0 8px;font-size:13px;line-height:1.4;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__composer .pmw-chat__photo-error{margin:0 8px}.pmw-chat__photo{display:block;max-width:100%;max-height:220px;border-radius:14px}.pmw-chat__fallback{--pmw-chat-gap: 14px;display:flex;flex-direction:column;gap:var(--pmw-chat-gap);margin-top:16px}.pmw-chat__fb-title{margin:0;font-size:20px;font-weight:600;line-height:1.3}.pmw-chat__fb-intro{margin:-8px 0 0;font-size:15px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__field{display:flex;flex-direction:column;gap:4px}.pmw-chat__field label{font-size:14px;font-weight:600}.pmw-chat__field input,.pmw-chat__field textarea{font:inherit;font-size:16px;padding:10px 14px;border-radius:12px;resize:none;border:1px solid var(--pmw-chat-field-border, #7d8590);background:#fff;color:var(--pmw-chat-field-fg, #1f2328)}.pmw-chat__fallback .pmw-chat__send{align-self:stretch;min-height:48px;border-radius:24px}.pmw-chat__error{margin:0;font-size:14px;color:var(--pmw-chat-error, #b42318)}.pmw-chat__error:empty{display:none}.pmw-chat__sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.pmw-chat[data-unread] .pmw-chat__bubble{position:relative}.pmw-chat[data-unread] .pmw-chat__bubble:after{content:"";position:absolute;top:2px;left:38px;width:12px;height:12px;border-radius:50%;background:var(--pmw-chat-error, #b42318);box-shadow:0 0 0 2px #fff}.pmw-chat__fb-done{margin:12px 0 0;padding:12px 14px;border-radius:12px;background:#e9f6ee;color:#14532d;border:1px solid #b7dfc4;font-weight:500}
2
+ `;var Mt="400px 0px";var st=new Set,pe=null;function ct(r,{doc:p=document,attrs:o={}}={}){if(st.has(r)||(st.add(r),p.querySelector(`script[src="${r}"]`)))return;let t=p.createElement("script");t.src=r,t.async=!0;for(let[f,m]of Object.entries(o))t.setAttribute(f,m);p.head.append(t)}function lt({doc:r=document,hostname:p=globalThis.location?.hostname??""}={}){let o=r.getElementById("pmw-hosts");if(!o)return!1;let t;try{t=JSON.parse(o.textContent??"")}catch{return!1}if(!Array.isArray(t))return!1;let f=String(p).toLowerCase();return t.some(m=>typeof m=="string"&&m.toLowerCase()===f)}function Nt(r){return pe||(pe=new Promise(p=>{let o=null,t=null,f=0,m=r.PerformanceObserver?.supportedEntryTypes??[],w=typeof r.PerformanceObserver=="function"&&m.includes("paint"),l=()=>{o===null||w&&t===null||p(Math.max(o,t??0,f))};if(w)try{new r.PerformanceObserver(x=>{for(let v of x.getEntries())v.name==="first-contentful-paint"&&(t=v.startTime);l()}).observe({type:"paint",buffered:!0}),m.includes("largest-contentful-paint")&&new r.PerformanceObserver(x=>{for(let v of x.getEntries())f=Math.max(f,v.startTime)}).observe({type:"largest-contentful-paint",buffered:!0})}catch{t=0}let u=()=>{o=r.performance.now(),l()};r.document.readyState==="complete"?u():r.addEventListener("load",u,{once:!0})}),pe)}function Me(r,p,{win:o=window,rootMargin:t=Mt,settleMs:f=1500}={}){let m=null,w=!1,l=()=>{w||(w=!0,m?.disconnect(),r.removeEventListener("focusin",l),r.removeEventListener("pointerdown",l),p())};r.addEventListener("focusin",l),r.addEventListener("pointerdown",l);let u=Nt(o),x=async()=>{let v=await u;o.setTimeout(l,Math.max(0,v+f-o.performance.now()))};return typeof o.IntersectionObserver=="function"?(m=new o.IntersectionObserver(v=>{v.some(k=>k.isIntersecting)&&(m.disconnect(),x())},{rootMargin:t}),m.observe(r)):x(),{run:l}}var Pt="https://challenges.cloudflare.com/turnstile/v0/api.js";var pt=15e3;function dt(r=document){ct(Pt,{doc:r})}var ht={unauthorized:4401,expired:4410,rpc:4426,deleted:4404};var Rt={fr:{assistant:"Assistant virtuel",openLabel:"Assistant virtuel, ouvrir le clavardage",dismissTeaser:"Masquer ce message",close:"Fermer le clavardage",panelTitle:"Clavardage",automated:"R\xE9ponses automatiques, tir\xE9es du contenu de ce site.",privacy:"Confidentialit\xE9",you:"Vous",messageLabel:"Votre message",send:"Envoyer",live:"Vous r\xE9pond en direct",relay:"{name} a pris le relais",relayGeneric:"Une personne de l\u2019\xE9quipe a pris le relais",handback:"L\u2019assistant virtuel a repris la conversation",oneMoment:"Un instant\u2026",reconnecting:"Connexion perdue, reconnexion\u2026",notSent:"Non envoy\xE9.",retry:"R\xE9essayer",ended:"Cette conversation est termin\xE9e.",newConversation:"Nouvelle conversation",reload:"Rechargez la page pour continuer.",unavailable:"Un probl\xE8me est survenu. R\xE9essayez dans un moment.",turnstile:"L\u2019envoi n\u2019a pas abouti. R\xE9essayez.",tooMany:"Un instant, puis r\xE9essayez.",fallbackTitle:"Laissez-nous vos coordonn\xE9es",fallbackCap:"Laissez vos coordonn\xE9es et nous vous recontacterons.",fallbackDown:"Laissez vos coordonn\xE9es et nous vous recontacterons.",name:"Votre nom",contact:"T\xE9l\xE9phone ou courriel",need:"Votre demande",fallbackSend:"Envoyer la demande",fallbackSent:"Merci, votre demande a \xE9t\xE9 transmise.",fallbackMissing:"Remplissez les trois champs.",said:"{name}\xA0: {text}",attachPhoto:"Joindre une photo",photoHint:"\xC9vitez les photos de personnes ou de pi\xE8ces d\u2019identit\xE9.",photoAlt:"Votre photo",photoSent:"Photo envoy\xE9e",photoLimit:"Trois photos au plus par conversation.",photoTooLarge:"Cette photo est trop lourde.",photoUnreadable:"Ce fichier n\u2019est pas une photo lisible.",photoFailed:"La photo n\u2019a pas pu \xEAtre envoy\xE9e. R\xE9essayez.",demoAsk:"Pouvez-vous m\u2019en dire plus sur {topic}?",demoQuestion:"Quels sont vos d\xE9lais?",demoAnswer:"(D\xE9monstration) Ici, l\u2019assistant r\xE9pond \xE0 partir du contenu de votre site.",demoHint:"Aper\xE7u\xA0: la conversation est d\xE9sactiv\xE9e."},en:{assistant:"Virtual assistant",openLabel:"Virtual assistant, open the chat",dismissTeaser:"Hide this message",close:"Close the chat",panelTitle:"Chat",automated:"Automatic answers, drawn from this site\u2019s content.",privacy:"Privacy",you:"You",messageLabel:"Your message",send:"Send",live:"Replying live",relay:"{name} took over",relayGeneric:"A team member took over",handback:"The virtual assistant is back in the conversation",oneMoment:"One moment\u2026",reconnecting:"Connection lost, reconnecting\u2026",notSent:"Not sent.",retry:"Retry",ended:"This conversation has ended.",newConversation:"New conversation",reload:"Reload the page to continue.",unavailable:"Something went wrong. Try again in a moment.",turnstile:"Sending did not go through. Try again.",tooMany:"One moment, then try again.",fallbackTitle:"Leave us your details",fallbackCap:"Leave your details and we will get back to you.",fallbackDown:"Leave your details and we will get back to you.",name:"Your name",contact:"Phone or email",need:"Your request",fallbackSend:"Send the request",fallbackSent:"Thank you, your request was sent.",fallbackMissing:"Fill in all three fields.",said:"{name}: {text}",attachPhoto:"Attach a photo",photoHint:"Avoid photos of people or of identity documents.",photoAlt:"Your photo",photoSent:"Photo sent",photoLimit:"Three photos at most per conversation.",photoTooLarge:"This photo is too large.",photoUnreadable:"This file is not a readable photo.",photoFailed:"The photo could not be sent. Try again.",demoAsk:"Can you tell me more about {topic}?",demoQuestion:"What are your lead times?",demoAnswer:"(Demo) Here, the assistant answers from your site\u2019s content.",demoHint:"Preview: the conversation is turned off."}},de=r=>String(r??"").slice(0,2).toLowerCase()==="en"?"en":"fr";function Ne(r,p={}){let o=Rt[de(r)],t={...o};for(let[f,m]of Object.entries(p??{}))typeof m=="string"&&f in o&&(t[f]=m);return t}var Y=(r,p)=>r.replace(/\{(\w+)\}/g,(o,t)=>t in p?String(p[t]):o);var he="pmw-chat",ut=[1e3,2e3,4e3,8e3,16e3,3e4],ft=3,qt="chat-lead",It=3,zt=1600,Ht=.85,Dt=3e3,Ut=7200*1e3,Bt=8e3,Ft=40,$t=3e3,wt=8e3,Jt='<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 8"/></svg>';async function jt(r,p){let o=await r.createImageBitmap(p,{imageOrientation:"from-image"}),t=Math.min(1,zt/Math.max(o.width,o.height)),f=Math.max(1,Math.round(o.width*t)),m=Math.max(1,Math.round(o.height*t)),w=r.document.createElement("canvas");return w.width=f,w.height=m,w.getContext("2d").drawImage(o,0,0,f,m),o.close?.(),new Promise((l,u)=>w.toBlob(x=>x?l(x):u(new Error("encode")),"image/jpeg",Ht))}var Kt={fr:{nos:"vos",notre:"votre",nous:"vous"},en:{our:"your",us:"you",we:"you"}},Gt={fr:/^une question sur (.+?)\s*\?$/i,en:/^(?:a |any )?questions? (?:about|on) (.+?)\s*\?$/i};function Vt(r,p){let o=de(p),t=Ne(o),f=String(r??"").trim().match(Gt[o]);if(!f)return t.demoQuestion;let m=f[1].replace(/[\p{L}’']+/gu,w=>Kt[o][w.toLowerCase()]??w);return Y(t.demoAsk,{topic:m})}var Yt='<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>';function i(r,p,o={},t){let f=r.createElement(p);for(let[m,w]of Object.entries(o))w!=null&&w!==!1&&f.setAttribute(m,w===!0?"":w);return t!==void 0&&(f.textContent=t),f}var Xt=/^(?:300\d{3}|600\d{3}|110600|110620)$/,Wt=r=>Xt.test(String(r??"")),_t=200,Qt=(r,p)=>{let o=!1,t=l=>{let u=r.turnstile;if(!(l.el.hasAttribute("data-pmw-rendered")||!l.shown())){l.el.setAttribute("data-pmw-rendered","");try{l.id=u.render(l.el,l.params)}catch{}}},f=()=>{if(!r.turnstile?.render)return!1;for(let l of p)t(l);return!0},m=()=>{if(dt(r.document),o||f())return;o=!0;let l=0,u=()=>{!f()&&++l<600&&r.setTimeout(u,100)};r.setTimeout(u,100)},w=l=>p.find(u=>u.form===l);for(let l of p)l.params["error-callback"]=u=>(l.ask(!1),Wt(u)&&!l.retried&&l.id!==void 0?(l.retried=!0,r.setTimeout(()=>{try{r.turnstile?.reset(l.id)}catch{l.failed=!0}},0)):l.failed=!0,!0);return{load:m,async token(l){m();let u=w(l),x=0;for(;;){if(u?.failed)return null;let v=l.querySelector('input[name="cf-turnstile-response"]')?.value??"";if(v)return v;if(!u?.asking()){if(x>=pt)return"";x+=_t}await new Promise(k=>r.setTimeout(k,_t))}},reset(l){let u=w(l);!u||u.id===void 0||u.asking()||(u.failed=!1,u.retried=!1,r.turnstile?.reset(u.id))}}};function da(r,p={}){if(r.pmwChat)return r.pmwChat;let o=p.win??window,t=o.document,f=p.WebSocket??o.WebSocket,m=p.fetch??((...e)=>o.fetch(...e)),w=[],l=p.turnstile??null,u=p.uuid??(()=>o.crypto.randomUUID()),x=p.shrink??(e=>jt(o,e)),v=p.preview??(e=>new Promise((a,n)=>{let c=new o.FileReader;c.onload=()=>a(String(c.result)),c.onerror=()=>n(c.error),c.readAsDataURL(e)})),k={};try{k=JSON.parse(r.querySelector("[data-pmw-chat-config]")?.textContent??"{}")}catch{}let X=de(k.lang),s=Ne(X,k.strings),Pe=r.dataset.pmwChatOpener??"",Re=r.querySelector(".pmw-chat__bubble"),g=p.demo??(k.demo===!0||!lt({doc:t,hostname:o.location.hostname}));t.getElementById("pmw-chat-style")||t.head.append(i(t,"style",{id:"pmw-chat-style"},it));let S=i(t,"div",{class:"pmw-chat__panel",role:"dialog","aria-modal":"true","aria-labelledby":"pmw-chat-name",hidden:!0});S.inert=!0;let qe=i(t,"div",{class:"pmw-chat__head"}),me=r.querySelector(".pmw-chat__avatar")?.cloneNode(!0)??i(t,"span",{class:"pmw-chat__avatar","aria-hidden":"true"});for(let e of me.querySelectorAll("[id]")){let a=e.id;e.id=`${a}-head`;for(let n of me.querySelectorAll(`[mask="url(#${a})"]`))n.setAttribute("mask",`url(#${a}-head)`)}let Ie=i(t,"div",{class:"pmw-chat__who"}),ze=i(t,"p",{class:"pmw-chat__name",id:"pmw-chat-name"}),W=i(t,"p",{class:"pmw-chat__role"}),He=i(t,"span",{class:"pmw-chat__face"}),De=i(t,"span",{class:"pmw-chat__dot","aria-hidden":"true",hidden:!0}),Q=i(t,"button",{type:"button",class:"pmw-chat__close","aria-label":s.close});Q.innerHTML=Yt;let Z=i(t,"p",{class:"pmw-chat__notice"},s.automated);k.privacyHref&&(Z.append(" "),Z.append(i(t,"a",{href:k.privacyHref},s.privacy))),He.append(me,De),Ie.append(ze,W,Z),qe.append(He,Ie,Q);let T=i(t,"ol",{class:"pmw-chat__log"}),gt=i(t,"li",{class:"pmw-chat__msg pmw-chat__msg--bot pmw-chat__msg--greeting"},Pe),A=i(t,"li",{class:"pmw-chat__typing","aria-hidden":"true",hidden:!0});A.append(i(t,"span"),i(t,"span"),i(t,"span")),T.append(gt);let ue=i(t,"p",{class:"pmw-chat__state",role:"status"}),Ue=i(t,"p",{class:"pmw-chat__sr","aria-live":"polite"}),Be=(e,a)=>{let n=i(t,"div",{class:"pmw-chat__check"}),c=i(t,"div",{class:"pmw-chat__check-in"});c.inert=!0;let d=le=>{n.toggleAttribute("data-ask",le),c.inert=!le,E()},_=i(t,"div",{class:"pmw-chat__turnstile"}),O={el:_,form:e,shown:a,ask:d,asking:()=>n.hasAttribute("data-ask"),params:{sitekey:k.siteKey??"",action:"chat",theme:"light",language:X,appearance:"interaction-only",size:"flexible","refresh-expired":"auto",callback:()=>{O.retried=!1,O.failed=!1,d(!1)},"before-interactive-callback":()=>d(!0)}};return w.push(O),c.append(_),n.append(c),n},y=i(t,"form",{class:"pmw-chat__composer",novalidate:!0}),L=i(t,"textarea",{id:"pmw-chat-input",name:"message",rows:"1",maxlength:String(2e3),placeholder:s.messageLabel,autocomplete:"off",disabled:g,"aria-describedby":g?"pmw-chat-demo-hint":null}),bt=i(t,"button",{type:"submit",class:"pmw-chat__send",disabled:g},s.send),fe=i(t,"div",{class:"pmw-chat__box"});fe.append(i(t,"label",{class:"pmw-chat__sr",for:"pmw-chat-input"},s.messageLabel),L,bt),g||y.append(Be(y,()=>!0)),y.append(fe),g&&y.append(i(t,"p",{class:"pmw-chat__hint",id:"pmw-chat-demo-hint"},s.demoHint));let F=null,z=i(t,"p",{class:"pmw-chat__error pmw-chat__photo-error",role:"alert"});if(k.photos===!0){let e=i(t,"label",{class:"pmw-chat__attach"});F=i(t,"input",{type:"file",class:"pmw-chat__sr",accept:"image/*",multiple:!0,"aria-describedby":"pmw-chat-photo-hint",disabled:g}),e.innerHTML=Jt,e.prepend(F),e.append(i(t,"span",{class:"pmw-chat__sr"},s.attachPhoto)),fe.insertBefore(e,L),y.append(i(t,"p",{class:"pmw-chat__hint",id:"pmw-chat-photo-hint"},s.photoHint),z)}let ee=i(t,"div",{class:"pmw-chat__ended",hidden:!0}),Fe=i(t,"p",{},s.ended),te=i(t,"button",{type:"button",class:"pmw-chat__restart"},s.newConversation);ee.append(Fe,te);let b=i(t,"form",{class:"pmw-chat__fallback",novalidate:!0,hidden:!0,"aria-labelledby":"pmw-chat-fb-title"}),$e=i(t,"p",{class:"pmw-chat__fb-intro"}),we=(e,a,n)=>{let c=`pmw-chat-fb-${e}`,d=i(t,"div",{class:"pmw-chat__field"});return d.append(i(t,"label",{for:c},s[e]),i(t,a,{id:c,name:e,required:!0,...n})),d},$=i(t,"p",{class:"pmw-chat__error",role:"alert"});b.append(i(t,"p",{class:"pmw-chat__fb-title",id:"pmw-chat-fb-title"},s.fallbackTitle),$e,we("name","input",{autocomplete:"name",maxlength:"100"}),we("contact","input",{autocomplete:"on",maxlength:"200"}),we("need","textarea",{rows:"3",maxlength:"2000"}),$,i(t,"button",{type:"submit",class:"pmw-chat__send"},s.fallbackSend)),g||b.insertBefore(Be(b,()=>!b.hidden),$);let M=i(t,"div",{class:"pmw-chat__body"});M.append(T,ue,ee,b),S.append(qe,M,y,Ue),r.append(S),l??(l=Qt(o,w));let J=!0,Je=0,_e=0,xt=()=>M.scrollHeight-M.scrollTop-M.clientHeight;M.addEventListener("scroll",()=>{let e=M.scrollTop;xt()<=Ft?J=!0:e<Je&&(J=!1),Je=e},{passive:!0});let vt=e=>o.requestAnimationFrame?o.requestAnimationFrame(e):o.setTimeout(e,16);function E(e=!1,a=!1){e&&(J=!0),!(!J||_e)&&(_e=vt(()=>{if(_e=0,!J)return;let n=a||!!o.matchMedia?.("(prefers-reduced-motion: reduce)").matches;M.scrollTo({top:M.scrollHeight,behavior:n?"instant":"smooth"})}))}if(typeof o.ResizeObserver=="function"){let e=new o.ResizeObserver(()=>E());e.observe(T),e.observe(y)}let h=null;if(!g)try{h=JSON.parse(o.sessionStorage.getItem(he)??"null")}catch{h=null}if(h&&typeof h.at=="number"&&!(Date.now()-h.at<Ut)){h=null;try{o.sessionStorage.removeItem(he)}catch{}}let ge=()=>{try{o.sessionStorage.setItem(he,JSON.stringify(h))}catch{}},yt=()=>{h&&(h.at=Date.now(),ge())},C=null,be=0,xe=null,j=null,ae=0,K="bot",R=null,H=!1,je=0,ve=null,ye=new Set,q=new Map,ne=new Set,ke=0,Ke=()=>new URLSearchParams(o.location.hash.slice(1)).get("pmw-chat-botName")?.trim()||null,Ge=g?Ke():null,Se=()=>g?Ge:h?.botName||null;function G(){let e=K==="human";ze.textContent=e?R||s.assistant:Se()||s.assistant,W.textContent=e?s.live:Se()?s.assistant:"",W.hidden=W.textContent==="",Z.hidden=e,De.hidden=!e,S.dataset.mode=K}G();let N=e=>{ue.textContent=e??"",ue.hidden=!e};N("");let Ve=e=>e.role==="visitor"?s.you:e.role==="owner"?e.by||R||s.assistant:Se()||s.assistant;function D(e,a){let n=i(t,"li",{class:`pmw-chat__msg pmw-chat__msg--${a}`,"data-id":e}),c=i(t,"span",{class:"pmw-chat__sr"}),d=i(t,"span",{class:"pmw-chat__text"});return n.append(c,d),T.insertBefore(n,A.parentNode===T?A:null),n}let Ye=()=>A.parentNode===T?A.previousElementSibling:T.lastElementChild,P=(e,a)=>e.classList.contains(a);function Xe(e,a,n,c){let d=i(t,"li",{class:e,role:"separator","aria-label":n}),_=i(t,"span",{class:"pmw-chat__relay-label","aria-hidden":"true"});_.append(i(t,"span",{class:a}),n),d.append(_),T.insertBefore(d,c??(A.parentNode===T?A:null)),E()}function We(e,a){for(let n=e?e.previousElementSibling:Ye();n;n=n.previousElementSibling){if(P(n,"pmw-chat__relay")||P(n,"pmw-chat__msg--owner"))return;if(P(n,"pmw-chat__msg--bot")||P(n,"pmw-chat__handback"))break}Xe("pmw-chat__relay","pmw-chat__relay-dot",a?Y(s.relay,{name:a}):s.relayGeneric,e)}function Te(e){for(let a=e?e.previousElementSibling:Ye();;a=a.previousElementSibling){if(!a||P(a,"pmw-chat__handback")||P(a,"pmw-chat__msg--bot"))return;if(P(a,"pmw-chat__relay")||P(a,"pmw-chat__msg--owner"))break}Xe("pmw-chat__handback","pmw-chat__relay-dot pmw-chat__relay-dot--back",s.handback,e)}let Qe=e=>[...T.querySelectorAll("li[data-id]")].find(a=>a.dataset.id===e),I=(e,a,n)=>{e.firstChild.textContent=Y(s.said,{name:Ve(a),text:""}),e.lastChild.textContent=n===""&&a.photos?.length&&!e.querySelector("img")?s.photoSent:n};function U(){A.hidden=!0,o.clearTimeout(ve)}function Le(e){A.hidden=!1,T.append(A),o.clearTimeout(ve),e&&(ve=o.setTimeout(U,e)),E()}function Ze(e,{announce:a}){if(ye.has(e.seq))return;ye.add(e.seq),ae=Math.max(ae,e.seq);let n=Qe(e.id);n||(n=D(e.id,e.role),e.role==="owner"?We(n,e.by||R):e.role==="bot"&&Te(n)),n.classList.remove("is-streaming","is-pending","is-failed"),n.querySelector(".pmw-chat__retry")?.remove(),I(n,e,e.text),e.role==="visitor"?(q.delete(e.id),e.photos?.length&&ne.add(e.id)):(U(),a&&(Ue.textContent=Y(s.said,{name:Ve(e),text:e.text})),S.hidden&&r.setAttribute("data-unread","")),E()}function kt(e,a){U();let n=Qe(e);n||(n=D(e,"bot"),Te(n),n.classList.add("is-streaming"),I(n,{role:"bot"},"")),n.lastChild.textContent+=a,E()}if(g){I(D("demo-question","visitor"),{role:"visitor"},Vt(Pe,X));let e=D("demo-answer","bot");I(e,{role:"bot"},s.demoAnswer),o.addEventListener("hashchange",()=>{Ge=Ke(),G(),I(e,{role:"bot"},s.demoAnswer)})}function oe(e){if(e.failed=!0,e.li.classList.remove("is-pending"),e.li.classList.add("is-failed"),e.li.querySelector(".pmw-chat__retry"))return;let a=i(t,"span",{class:"pmw-chat__retry"},`${s.notSent} `),n=i(t,"button",{type:"button"},s.retry);n.addEventListener("click",()=>{e.failed=!1,e.tries=0,a.remove(),e.li.classList.replace("is-failed","is-pending"),h?re()?Ee(e):B():Ae()}),a.append(n),e.li.append(a)}let re=()=>!!C&&C.readyState===1;function Ee(e){if(!(e.failed||!re())){if(e.tries>=ft)return oe(e);e.tries++,C.send(JSON.stringify({type:"message",id:e.id,text:e.text}))}}function B(){if(!h||H||C)return;o.clearTimeout(xe);let e=o.location.protocol==="https:"?"wss:":"ws:",a=new URLSearchParams({c:h.c,k:h.k}),n=new f(`${e}//${o.location.host}/api/chat/ws?${a}`);C=n,n.onopen=()=>{be=0,ce&&(V(),Le(wt)),N(""),n.send(JSON.stringify({type:"hello",since:ae}));for(let c of q.values())Ee(c)},n.onmessage=c=>{let d;try{d=JSON.parse(c.data)}catch{return}Tt(d)},n.onclose=c=>{C===n&&(C=null);for(let _ of q.values())_.tries>=ft&&oe(_);if(c.code>=4e3&&c.code<5e3)return St(c.code);if(H)return;V(),N(s.reconnecting);let d=ut[Math.min(be,ut.length-1)];be++,xe=o.setTimeout(B,d)}}function St(e){if(e===ht.rpc)return ie(s.reload,!1);ie(s.ended,!0)}function ie(e,a){H=!0,o.clearTimeout(xe),N(""),Fe.textContent=e,te.hidden=!a,ee.hidden=!1,y.hidden=!0;for(let n of q.values())oe(n);E()}function et(e){K=e.mode==="human"?"human":"bot",R=typeof e.ownerName=="string"?e.ownerName:null,G(),K==="human"?We(null,R):Te(null),e.closed&&ie(s.ended,!0)}function Tt(e){switch(e?.type){case"history":typeof e.ownerName=="string"&&(R=e.ownerName);for(let a of e.messages??[])Ze(a,{announce:!1});return et(e);case"message":return yt(),Ze(e.msg,{announce:e.msg?.role!=="visitor"});case"delta":return kt(e.id,e.text);case"typing":return e.role==="visitor"?void 0:Le(wt);case"status":return et(e);case"fallback":return nt(e.reason==="cap"?s.fallbackCap:s.fallbackDown);case"lead":return at();case"error":(e.code==="closed"||e.code==="deleted")&&ie(s.ended,!0)}}let tt=!1;function at(){if(!(tt||h?.lead)){tt=!0,h&&(h.lead=!0,ge());try{o.pmw?.event(qt)}catch{}}}let se=new Set;async function Ce(e,a){let n="";if(se.has(e)){se.delete(e);try{l.reset(e)}catch{}}try{n=await l.token(e)}catch{n=""}if(n===null)return se.add(e),{ok:!1,error:s.turnstile,cap:!1,turnstile:!0};let c,d=null;try{c=await m("/api/chat/start",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({token:n,lang:X,page:o.location.pathname,...a})}),d=await c.json()}catch{c=null}return se.add(e),c?.ok&&d?{ok:!0,data:d}:{ok:!1,error:c?.status===403?s.turnstile:c?.status===429?s.tooMany:null,cap:c?.status===409&&d?.error==="cap",turnstile:c?.status===403}}let ce=null,V=()=>{o.clearTimeout(ce),ce=null};function Ae(){return j||h||(N(""),Le(),ce=o.setTimeout(()=>N(s.oneMoment),Bt),j=Ce(y,{}).then(e=>{if(j=null,e.ok&&e.data.conversationId&&e.data.visitorKey){h={c:e.data.conversationId,k:e.data.visitorKey,botName:typeof e.data.botName=="string"?e.data.botName:null,at:Date.now()},ge(),G(),B();return}V(),U();for(let a of q.values())oe(a);if(e.error)return N(e.error);N(""),nt(e.cap?s.fallbackCap:s.fallbackDown)})),j}function nt(e){U(),V(),N(""),$e.textContent=e,b.hidden=!1,y.hidden=!0,g||l.load(),b.querySelector("input")?.focus()}b.addEventListener("submit",async e=>{e.preventDefault();let a=O=>b.elements[O].value.trim(),n={name:a("name"),contact:a("contact"),need:a("need")};if(!n.name||!n.contact||!n.need){$.textContent=s.fallbackMissing;return}$.textContent="";let c=b.querySelector('button[type="submit"]');if(c.disabled)return;c.disabled=!0;let d={fallbackLead:n,...h?.c&&h?.k?{c:h.c,k:h.k}:{}},_=await Ce(b,d);if(!_.ok&&_.turnstile&&(_=await Ce(b,d)),c.disabled=!1,!_.ok){$.textContent=_.error??s.unavailable;return}b.replaceChildren(i(t,"p",{class:"pmw-chat__fb-done",role:"status"},s.fallbackSent)),at()});let Lt={413:"photoTooLarge",415:"photoUnreadable",409:"photoLimit",429:"tooMany"};async function Et(e){let a,n;try{a=await x(e),n=await v(a)}catch{z.textContent=s.photoUnreadable;return}let c=u(),d=D(c,"visitor");d.classList.add("is-pending");let _=i(t,"img",{class:"pmw-chat__photo",src:n,alt:s.photoAlt});_.addEventListener("load",()=>E(),{once:!0}),d.insertBefore(_,d.lastChild),I(d,{role:"visitor"},""),E(!0),h||await Ae();let O=null;if(h){let le=new URLSearchParams({c:h.c,k:h.k,id:c});try{O=await m(`/api/chat/photo?${le}`,{method:"POST",headers:{"content-type":"image/jpeg"},body:a})}catch{O=null}}if(O?.ok){d.classList.remove("is-pending"),ne.add(c),C||B();return}d.remove(),z.textContent=s[Lt[O?.status]]??s.photoFailed}F?.addEventListener("change",async()=>{let e=[...F.files??[]];if(F.value="",z.textContent="",H||g||e.length===0)return;let a=It-ne.size-ke;e.length>a&&(z.textContent=s.photoLimit);let n=e.slice(0,Math.max(0,a));ke+=n.length;for(let c of n)try{await Et(c)}finally{ke--}});function ot(){let e=L.value.trim();if(!e||H||g)return;L.value="",rt();let a={id:u(),text:e.slice(0,2e3),tries:0,failed:!1};a.li=D(a.id,"visitor"),a.li.classList.add("is-pending"),I(a.li,{role:"visitor"},a.text),q.set(a.id,a),E(!0),h?re()?Ee(a):B():Ae()}y.addEventListener("submit",e=>{e.preventDefault(),ot()}),L.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&!e.isComposing&&(e.preventDefault(),ot())});let rt=()=>{L.style.height="auto",L.style.height=L.value?`${Math.min(L.scrollHeight,132)}px`:""};L.addEventListener("input",()=>{rt();let e=Date.now();re()&&e-je>$t&&(je=e,C.send(JSON.stringify({type:"typing"})))}),te.addEventListener("click",()=>{h=null;try{o.sessionStorage.removeItem(he)}catch{}try{C?.close(1e3)}catch{}C=null,H=!1,ae=0,ye.clear(),q.clear(),ne.clear(),z.textContent="",K="bot",R=null,G();for(let e of[...T.querySelectorAll("li[data-id], .pmw-chat__relay, .pmw-chat__handback")])e.remove();U(),V(),ee.hidden=!0,y.hidden=!1,L.focus()});let Ct=()=>[...S.querySelectorAll('a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])')].filter(e=>!e.disabled&&!e.closest("[hidden]"));S.addEventListener("keydown",e=>{if(e.key==="Escape"){e.preventDefault(),Oe();return}if(e.key!=="Tab")return;let a=Ct();if(a.length===0)return;let n=a[0],c=a[a.length-1];e.shiftKey&&t.activeElement===n?(e.preventDefault(),c.focus()):!e.shiftKey&&t.activeElement===c&&(e.preventDefault(),n.focus())}),Q.addEventListener("click",e=>Oe({keyboard:e.detail===0}));function At(){g||l.load(),S.hidden=!1,S.inert=!1,r.setAttribute("data-open",""),r.removeAttribute("data-unread"),h&&B(),(g?Q:y.hidden?b.hidden?te:b.querySelector("input"):L)?.focus(),E(!0,!0)}function Oe({keyboard:e=!0}={}){S.hidden=!0,S.inert=!0,r.removeAttribute("data-open"),e&&Re?.focus(),r.dispatchEvent(new o.FocusEvent("focusout",{relatedTarget:e?Re:null}))}return g||Me(r,()=>l.load(),{win:o,settleMs:Dt}),r.pmwChat={open:At,close:Oe,panel:S},r.pmwChat}export{ut as BACKOFF_MS,qt as GOAL,Ut as IDLE_MS,It as MAX_PHOTOS,ft as MAX_TRIES,zt as PHOTO_EDGE,Ht as PHOTO_QUALITY,Ft as PIN_SLACK,he as SESSION_KEY,Bt as SLOW_START_MS,Dt as WARM_MS,Vt as demoQuestion,da as mountChat,Wt as retryable};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmeweb/plugin-chat",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "PmeWeb chat plugin: the bubble, the lazy chat panel, the site Worker's /api/chat routes and the build-time context file. The conversations, the model and the alerts live in the pmeweb-chat service.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/strings.js CHANGED
@@ -23,7 +23,8 @@ export const STRINGS = {
23
23
  live: 'Vous répond en direct',
24
24
  relay: '{name} a pris le relais',
25
25
  relayGeneric: 'Une personne de l’équipe a pris le relais',
26
- connecting: 'Connexion…',
26
+ handback: 'L’assistant virtuel a repris la conversation',
27
+ oneMoment: 'Un instant…',
27
28
  reconnecting: 'Connexion perdue, reconnexion…',
28
29
  notSent: 'Non envoyé.',
29
30
  retry: 'Réessayer',
@@ -70,7 +71,8 @@ export const STRINGS = {
70
71
  live: 'Replying live',
71
72
  relay: '{name} took over',
72
73
  relayGeneric: 'A team member took over',
73
- connecting: 'Connecting…',
74
+ handback: 'The virtual assistant is back in the conversation',
75
+ oneMoment: 'One moment…',
74
76
  reconnecting: 'Connection lost, reconnecting…',
75
77
  notSent: 'Not sent.',
76
78
  retry: 'Retry',
package/src/trigger.js CHANGED
@@ -1,7 +1,11 @@
1
1
  // The inline part of the chat (dist/trigger.iife.js, under the manifest's inlineBudget). At page
2
2
  // open it fetches nothing: it shows the bubble on every host and schedules the opener. The panel
3
- // (dist/widget.js) is imported on the first touch of the bubble or the opener (@pmeweb/loader
4
- // onInteraction: press, hover, focus, key) and opened on a click. Off the site's real hosts the
3
+ // (dist/widget.js) is imported on the visitor's first sign of life ANYWHERE on the page
4
+ // (@pmeweb/loader onInteraction on the document, PAGE_EVENTS: a press, a focus, a key, a pointer
5
+ // moving, a scroll) and opened on a click. A test run (Lighthouse, PageSpeed) never does any of
6
+ // these, so it never fetches the widget before the opener's 20 s (the opener appearing imports it
7
+ // too: the visitor may open it next). Importing it that early is what lets the widget start
8
+ // Turnstile well before the panel opens (widget.js, « TURNSTILE »). Off the site's real hosts the
5
9
  // panel runs its demo (manifest preview: test); the widget decides that, not this file.
6
10
  //
7
11
  // THE DEMO FOLLOWS THE URL FRAGMENT, off the real hosts only (phase 4 spec 2.4), so the portal's
@@ -21,11 +25,13 @@
21
25
  // `inert` (out of the tab order and the accessibility tree): chat.css fades it out, then turns it
22
26
  // `visibility: hidden`. Never while the panel is open or while focus is inside the root: a focusout
23
27
  // re-checks, so closing the panel near the footer hides the bubble once focus moves on.
24
- import { isCanonical, onInteraction as interact } from '@pmeweb/loader'
28
+ import { INTERACTION_EVENTS, isCanonical, onInteraction as interact } from '@pmeweb/loader'
25
29
 
26
30
  export const OPENER_KEY = 'pmw-chat-opener'
27
31
  export const OPENER_DELAY_MS = 20000
28
32
  export const OPENER_SCROLL = 0.3
33
+ /** The page-wide signs of a person: the loader's own, plus a pointer moving and a scroll. */
34
+ export const PAGE_EVENTS = [...INTERACTION_EVENTS, 'pointermove', 'scroll']
29
35
 
30
36
  export function startChat({
31
37
  doc = document, win = window, hostname = win.location.hostname, importLazy = (u) => import(/* @vite-ignore */ u), onInteraction = interact,
@@ -36,13 +42,16 @@ export function startChat({
36
42
  const teaser = root.querySelector('.pmw-chat__teaser')
37
43
  let widget = null
38
44
  const load = () => (widget ??= importLazy(root.dataset.pmwLazy).then((m) => m.mountChat(root), (e) => { widget = null; throw e }))
39
- onInteraction(root, () => load().catch(() => {}))
45
+ const warm = () => load().catch(() => {})
46
+ const open = () => load().then((w) => w.open(), () => {})
47
+ const isOpen = () => root.hasAttribute('data-open')
48
+ onInteraction(doc, warm, { events: PAGE_EVENTS })
40
49
  const canonical = isCanonical({ doc, hostname })
41
50
  if (!canonical) {
42
51
  const view = () => {
43
52
  const v = new URLSearchParams(win.location.hash.slice(1)).get('pmw-chat')
44
- if (v === 'panel' && !root.hasAttribute('data-open')) load().then((w) => w.open(), () => {})
45
- else if (v === 'bubble' && widget) widget.then((w) => w.close(), () => {})
53
+ if (v === 'panel' && !isOpen()) open()
54
+ else if (v === 'bubble' && widget) widget.then((w) => w.close?.(), () => {})
46
55
  }
47
56
  view()
48
57
  win.addEventListener('hashchange', view)
@@ -50,7 +59,7 @@ export function startChat({
50
59
 
51
60
  let away = false
52
61
  const sync = (f = doc.activeElement) => {
53
- const h = away && !root.hasAttribute('data-open') && !root.contains(f)
62
+ const h = away && !isOpen() && !root.contains(f)
54
63
  root.toggleAttribute('inert', h)
55
64
  }
56
65
  const zone = doc.querySelector('[data-pmw-chat-avoid]') ?? [...doc.querySelectorAll('footer')].pop()
@@ -70,16 +79,17 @@ export function startChat({
70
79
  try { store?.setItem(OPENER_KEY, '1') } catch { /* nothing to remember with */ }
71
80
  }
72
81
  const show = () => {
73
- if (done || away || !teaser || root.hasAttribute('data-open')) return
82
+ if (done || away || !teaser || isOpen()) return
74
83
  finish()
75
84
  teaser.hidden = false
85
+ warm()
76
86
  }
77
87
  root.addEventListener('click', (e) => {
78
88
  const t = e.target.closest?.('[data-pmw-chat-open],[data-pmw-chat-dismiss]')
79
89
  if (!t) return
80
90
  if (teaser) teaser.hidden = true
81
91
  finish()
82
- if (t.hasAttribute('data-pmw-chat-open')) load().then((w) => w.open(), () => {})
92
+ if (t.hasAttribute('data-pmw-chat-open')) open()
83
93
  })
84
94
  if (!done && teaser && canonical) {
85
95
  win.setTimeout(show, OPENER_DELAY_MS)
package/src/widget.css CHANGED
@@ -14,7 +14,10 @@
14
14
  background: var(--pmw-chat-panel-bg, #ffffff); color: var(--pmw-chat-fg, #1f2328);
15
15
  font-family: var(--pmw-chat-font, inherit); font-size: 16px; line-height: 1.45;
16
16
  }
17
- .pmw-chat__panel[hidden], .pmw-chat__panel [hidden] { display: none !important; }
17
+ /* Closed, the panel stays laid out, transparent and inert (widget.js « TURNSTILE »: the challenge
18
+ is rendered into a box with a real size, never a display:none one). */
19
+ .pmw-chat__panel[hidden] { display: flex !important; opacity: 0; pointer-events: none; }
20
+ .pmw-chat__panel [hidden] { display: none !important; }
18
21
  @media (min-width: 640px) {
19
22
  .pmw-chat__panel {
20
23
  inset: auto;
@@ -97,15 +100,17 @@
97
100
  @media (prefers-reduced-motion: reduce) {
98
101
  .pmw-chat__typing span { animation: none; opacity: 0.7; }
99
102
  }
100
- /* Where a person takes over: a line, the green dot and « {name} a pris le relais », centred. */
101
- .pmw-chat__log > .pmw-chat__relay {
103
+ /* Where a person takes over: a line, the green dot and « {name} a pris le relais », centred. Where
104
+ they hand back: the same line, the dot in the muted colour (nobody is live any more). */
105
+ .pmw-chat__log > .pmw-chat__relay, .pmw-chat__log > .pmw-chat__handback {
102
106
  align-self: stretch; margin: 18px 0 8px; display: flex; align-items: center; gap: 10px;
103
107
  font-size: 13px; font-weight: 500; line-height: 1.4; color: var(--pmw-chat-muted, #4b5563);
104
108
  }
105
- .pmw-chat__relay::before, .pmw-chat__relay::after { content: ''; flex: 1; min-width: 16px; height: 1px; background: var(--pmw-chat-rule, #cfd5e1); }
109
+ .pmw-chat__relay::before, .pmw-chat__relay::after, .pmw-chat__handback::before, .pmw-chat__handback::after { content: ''; flex: 1; min-width: 16px; height: 1px; background: var(--pmw-chat-rule, #cfd5e1); }
106
110
  .pmw-chat__relay-label { display: flex; align-items: center; gap: 6px; text-align: center; }
107
111
  .pmw-chat__relay-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--pmw-chat-live, #1a7f37); }
108
- .pmw-chat__log > .pmw-chat__relay + li { margin-top: 0; }
112
+ .pmw-chat__relay-dot--back { background: var(--pmw-chat-muted, #4b5563); }
113
+ .pmw-chat__log > .pmw-chat__relay + li, .pmw-chat__log > .pmw-chat__handback + li { margin-top: 0; }
109
114
  .pmw-chat__state { margin: 8px 0 0; font-size: 14px; color: var(--pmw-chat-muted, #4b5563); }
110
115
  .pmw-chat__state:empty { display: none; }
111
116
  /* The end: a thin line, the sentence centred, an outlined pill. */
@@ -117,13 +122,16 @@
117
122
  background: var(--pmw-chat-panel-bg, #ffffff); color: var(--pmw-chat-link, #0a1b3d); font-weight: 600;
118
123
  }
119
124
 
120
- /* The composer: one rounded white field holding the photo button, the text and the send pill. */
125
+ /* The composer: one rounded white field holding the photo button, the text and the send pill.
126
+ 16 px on each side, so the field's focus ring (3 px, 2 px out) keeps 11 px off the panel's edge;
127
+ on a phone the bottom adds the home indicator's safe area. */
121
128
  .pmw-chat__composer {
122
- display: flex; flex-direction: column; gap: 6px; padding: 12px 12px 10px;
129
+ --pmw-chat-gap: 8px;
130
+ display: flex; flex-direction: column; gap: var(--pmw-chat-gap); padding: 14px 16px 16px;
123
131
  border-top: 1px solid var(--pmw-chat-line, #e6e9f0); background: var(--pmw-chat-panel-bg, #ffffff);
124
132
  }
125
133
  @media (max-width: 639.98px) {
126
- .pmw-chat__composer { padding: 10px 12px calc(14px + env(safe-area-inset-bottom, 0px)); }
134
+ .pmw-chat__composer { padding: 12px 16px calc(14px + env(safe-area-inset-bottom, 0px)); }
127
135
  }
128
136
  .pmw-chat__box {
129
137
  display: flex; align-items: flex-end; gap: 6px; padding: 4px; border-radius: 24px;
@@ -145,7 +153,23 @@
145
153
  background: var(--pmw-chat-accent, #0a1b3d); color: var(--pmw-chat-accent-fg, #ffffff);
146
154
  }
147
155
  .pmw-chat__send:disabled, .pmw-chat__box textarea:disabled { cursor: not-allowed; opacity: 0.6; }
148
- .pmw-chat__composer .pmw-turnstile, .pmw-chat__fallback .pmw-turnstile { align-self: stretch; }
156
+ /* Turnstile's row (widget.js « TURNSTILE »): collapsed, with no height and no gap, until Cloudflare
157
+ asks the visitor to act (its before-interactive callback sets data-ask); collapsed again on
158
+ success. Collapsed, its box keeps the row's full width and its natural height, out of the flow,
159
+ transparent and without pointer events (and inert), so the challenge never lays out in a 0 px box.
160
+ Full width, aligned with the field (the widget is size flexible, 300 px minimum: the composer's
161
+ inside is 358 px on a 390 px phone, 346 px in the 380 px desktop panel). In the composer it sits
162
+ ABOVE the field, which therefore never moves when it opens or closes. */
163
+ .pmw-chat__check {
164
+ position: relative; display: grid; grid-template-rows: 0fr; margin-bottom: calc(-1 * var(--pmw-chat-gap, 0px));
165
+ transition: grid-template-rows 0.25s ease, margin-bottom 0.25s ease;
166
+ }
167
+ .pmw-chat__check[data-ask] { grid-template-rows: 1fr; margin-bottom: 4px; }
168
+ .pmw-chat__check-in { min-height: 0; overflow: hidden; }
169
+ .pmw-chat__check:not([data-ask]) .pmw-chat__check-in { position: absolute; top: 0; left: 0; right: 0; overflow: visible; opacity: 0; pointer-events: none; }
170
+ @media (prefers-reduced-motion: reduce) {
171
+ .pmw-chat__check { transition: none; }
172
+ }
149
173
  /* The photo picker: a real file input, visually hidden inside its label, so Tab reaches it and the
150
174
  label shows its focus ring. 44 x 44 like every other control. */
151
175
  .pmw-chat__attach {
@@ -160,7 +184,7 @@
160
184
  .pmw-chat__photo { display: block; max-width: 100%; max-height: 220px; border-radius: 14px; }
161
185
 
162
186
  /* The contact form, when the chat cannot answer: a 20 px title, rounded fields, a full-width pill. */
163
- .pmw-chat__fallback { display: flex; flex-direction: column; gap: 14px; margin-top: 16px; }
187
+ .pmw-chat__fallback { --pmw-chat-gap: 14px; display: flex; flex-direction: column; gap: var(--pmw-chat-gap); margin-top: 16px; }
164
188
  .pmw-chat__fb-title { margin: 0; font-size: 20px; font-weight: 600; line-height: 1.3; }
165
189
  .pmw-chat__fb-intro { margin: -8px 0 0; font-size: 15px; color: var(--pmw-chat-muted, #4b5563); }
166
190
  .pmw-chat__field { display: flex; flex-direction: column; gap: 4px; }
@@ -179,3 +203,9 @@
179
203
  content: ''; position: absolute; top: 2px; left: 38px; width: 12px; height: 12px; border-radius: 50%;
180
204
  background: var(--pmw-chat-error, #b42318); box-shadow: 0 0 0 2px #ffffff;
181
205
  }
206
+ /* The contact form's confirmation: a box of its own, never plain text under the thread
207
+ (operator, 2026-11-13). #14532d on #e9f6ee is 8.19:1 (a11y-tools/contrast.mjs), whatever the site's palette. */
208
+ .pmw-chat__fb-done {
209
+ margin: 12px 0 0; padding: 12px 14px; border-radius: 12px;
210
+ background: #e9f6ee; color: #14532d; border: 1px solid #b7dfc4; font-weight: 500;
211
+ }
package/src/widget.js CHANGED
@@ -37,8 +37,8 @@
37
37
  // a visitor's question drawn from it (demoQuestion) and a canned answer, with the composer disabled
38
38
  // and a hint saying why. The client sees their face and colours in the « Contenu » preview.
39
39
  import css from './widget.css?inline'
40
- import { isCanonical } from '@pmeweb/loader'
41
- import { addScript as loadTurnstile, resetToken, tokenReady } from '@pmeweb/turnstile'
40
+ import { isCanonical, onApproach } from '@pmeweb/loader'
41
+ import { addScript as loadTurnstile, TOKEN_TIMEOUT_MS } from '@pmeweb/turnstile'
42
42
  import { CLOSE, MAX_TEXT } from './protocol.js'
43
43
  import { fill, langOf, stringsFor } from './strings.js'
44
44
 
@@ -49,6 +49,14 @@ export const GOAL = 'chat-lead'
49
49
  export const MAX_PHOTOS = 3
50
50
  export const PHOTO_EDGE = 1600
51
51
  export const PHOTO_QUALITY = 0.85
52
+ /** How long after the page has painted (and the widget was imported) Turnstile starts, unasked. */
53
+ export const WARM_MS = 3000
54
+ /** A conversation with no message for this long is over: the next one starts fresh (and pmeweb-chat closes it). */
55
+ export const IDLE_MS = 2 * 60 * 60 * 1000
56
+ /** A first start slower than this shows « Un instant… » under the typing dots. */
57
+ export const SLOW_START_MS = 8000
58
+ /** How close to the bottom of the thread (px) still counts as « at the bottom ». */
59
+ export const PIN_SLACK = 40
52
60
  const TYPING_SEND_MS = 3000
53
61
  const TYPING_SHOW_MS = 8000
54
62
 
@@ -90,14 +98,110 @@ function el(doc, tag, attrs = {}, text) {
90
98
  return e
91
99
  }
92
100
 
93
- const defaultTurnstile = (win) => ({
94
- load: () => loadTurnstile(win.document),
95
- async token(form) {
96
- await tokenReady(form, { win })
97
- return form.querySelector('input[name="cf-turnstile-response"]')?.value ?? ''
98
- },
99
- reset: (form) => resetToken(form, { win }),
100
- })
101
+ // TURNSTILE (operator, 2026-11-13). Loaded only when the panel opened, the challenge had no time to
102
+ // observe the visitor and often showed its « Vérifiez que vous êtes humain » box. So it now starts
103
+ // early, but never in a test run: the trigger imports this file on the visitor's first sign of life
104
+ // on the page (a scroll, a pointer moving, a press, a key), and mountChat then starts api.js through
105
+ // @pmeweb/loader onApproach on the chat root (fixed on screen): once the page has painted (`load`,
106
+ // first contentful paint, LCP, and this file's own import, whichever is latest) plus WARM_MS, or at
107
+ // once on a press or a focus in the chat, or when the panel opens. Lighthouse never interacts, so it
108
+ // never imports this file and never fetches api.js; a visitor who reads for a few seconds before
109
+ // opening the panel usually finds the token minted.
110
+ //
111
+ // EXPLICIT RENDERING, ONE RENDER PER BOX (1.3.2, after a 600010 on pmeweb.ca). api.js is the page's
112
+ // one copy, the same URL @pmeweb/turnstile adds for a contact form, and it renders IMPLICITLY every
113
+ // `.cf-turnstile` it finds: that is the form's widget, and the chat never renders, resets or alters
114
+ // a widget it does not own. Ours carry no `cf-turnstile` class, so implicit rendering cannot reach
115
+ // them; the chat renders each box itself, with its options and callbacks as an object (no global
116
+ // callback names), once: a `data-pmw-rendered` mark set before the call is the guard, never the
117
+ // DOM Cloudflare inserts. `turnstile.ready()` is not used: with api.js added async it throws
118
+ // (« Remove async/defer ... », measured 2026-11-13); `turnstile.render` existing is the signal, and
119
+ // api.js has finished its implicit pass before `window.turnstile` is visible (measured, Chromium
120
+ // and WebKit).
121
+ //
122
+ // A BOX WITH REAL DIMENSIONS. 1.3.1 rendered into a row collapsed to 0 px, in a panel and a form
123
+ // that were display:none (measured: the render saw a 0 px box). Now the closed panel stays laid out
124
+ // (transparent, inert, no pointer events), the collapsed row keeps its box at full width and natural
125
+ // height but transparent and out of the flow, and the contact form's box is rendered only once that
126
+ // form is shown. The row opens only when Cloudflare itself asks for the visitor
127
+ // (`before-interactive-callback`) and collapses again on success, so « Succès ! » goes away. The
128
+ // rule « never hide the box with a guessed rule » holds: the row follows Cloudflare's callbacks,
129
+ // never the iframe's size.
130
+ //
131
+ // ERRORS. A retryable code (300*, 600*, 110600, 110620: Cloudflare's docs say reset and retry) resets
132
+ // the widget ONCE, silently. A second failure keeps the row collapsed and marks the box failed: the
133
+ // next post does not wait for a token, it shows the neutral « L'envoi n'a pas abouti » line, and the
134
+ // post after that starts a fresh challenge. Any other code is a failure at once.
135
+ //
136
+ // THE TOKEN: Turnstile refreshes an unused one before its 300 s expiry (refresh-expired: auto). A
137
+ // used one is replaced just before the next post (« spent » below), never while the row is asking: a
138
+ // reset would wipe the box the visitor is solving. `reset(id)` names our widget, never every widget
139
+ // on the page (@pmeweb/turnstile rule 4).
140
+ const RETRYABLE = /^(?:300\d{3}|600\d{3}|110600|110620)$/
141
+ export const retryable = (code) => RETRYABLE.test(String(code ?? ''))
142
+ const POLL_MS = 200
143
+
144
+ const defaultTurnstile = (win, boxes) => {
145
+ let polling = false
146
+ const draw = (b) => {
147
+ const ts = win.turnstile
148
+ if (b.el.hasAttribute('data-pmw-rendered') || !b.shown()) return
149
+ b.el.setAttribute('data-pmw-rendered', '')
150
+ try { b.id = ts.render(b.el, b.params) } catch { /* the token call gives up after TOKEN_TIMEOUT_MS */ }
151
+ }
152
+ const render = () => {
153
+ if (!win.turnstile?.render) return false
154
+ for (const b of boxes) draw(b)
155
+ return true
156
+ }
157
+ const load = () => {
158
+ loadTurnstile(win.document)
159
+ if (polling || render()) return
160
+ polling = true
161
+ let n = 0
162
+ const tick = () => { if (!render() && ++n < 600) win.setTimeout(tick, 100) }
163
+ win.setTimeout(tick, 100)
164
+ }
165
+ const of = (form) => boxes.find((b) => b.form === form)
166
+ for (const b of boxes) {
167
+ b.params['error-callback'] = (code) => {
168
+ b.ask(false)
169
+ if (retryable(code) && !b.retried && b.id !== undefined) {
170
+ b.retried = true
171
+ win.setTimeout(() => { try { win.turnstile?.reset(b.id) } catch { b.failed = true } }, 0)
172
+ } else {
173
+ b.failed = true
174
+ }
175
+ return true
176
+ }
177
+ }
178
+ return {
179
+ load,
180
+ /** The token, '' after TOKEN_TIMEOUT_MS with no challenge on screen, null when the box failed. */
181
+ async token(form) {
182
+ load()
183
+ const b = of(form)
184
+ let waited = 0
185
+ for (;;) {
186
+ if (b?.failed) return null
187
+ const v = form.querySelector('input[name="cf-turnstile-response"]')?.value ?? ''
188
+ if (v) return v
189
+ if (!b?.asking()) {
190
+ if (waited >= TOKEN_TIMEOUT_MS) return ''
191
+ waited += POLL_MS
192
+ }
193
+ await new Promise((r) => win.setTimeout(r, POLL_MS))
194
+ }
195
+ },
196
+ reset(form) {
197
+ const b = of(form)
198
+ if (!b || b.id === undefined || b.asking()) return
199
+ b.failed = false
200
+ b.retried = false
201
+ win.turnstile?.reset(b.id)
202
+ },
203
+ }
204
+ }
101
205
 
102
206
  export function mountChat(root, deps = {}) {
103
207
  if (root.pmwChat) return root.pmwChat
@@ -105,7 +209,8 @@ export function mountChat(root, deps = {}) {
105
209
  const doc = win.document
106
210
  const WS = deps.WebSocket ?? win.WebSocket
107
211
  const fetchImpl = deps.fetch ?? ((...a) => win.fetch(...a))
108
- const turnstile = deps.turnstile ?? defaultTurnstile(win)
212
+ const boxes = []
213
+ let turnstile = deps.turnstile ?? null
109
214
  const uuid = deps.uuid ?? (() => win.crypto.randomUUID())
110
215
  const shrink = deps.shrink ?? ((file) => shrinkPhoto(win, file))
111
216
  // A data: URL, not a blob: one: site CSPs allow `img-src data:` (boilerplate worker), not blob:.
@@ -128,6 +233,9 @@ export function mountChat(root, deps = {}) {
128
233
 
129
234
  // ---- the panel --------------------------------------------------------------------------
130
235
  const panel = el(doc, 'div', { class: 'pmw-chat__panel', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'pmw-chat-name', hidden: true })
236
+ // Closed, the panel stays laid out (transparent, no pointer events) so its Turnstile box has a real
237
+ // size (« TURNSTILE » above); inert keeps it out of the tab order and the accessibility tree.
238
+ panel.inert = true
131
239
  const head = el(doc, 'div', { class: 'pmw-chat__head' })
132
240
  const avatar = root.querySelector('.pmw-chat__avatar')?.cloneNode(true) ?? el(doc, 'span', { class: 'pmw-chat__avatar', 'aria-hidden': 'true' })
133
241
  // An icon may hold a <mask id>: the clone gets its own ids, since the bubble (and the original
@@ -164,19 +272,38 @@ export function mountChat(root, deps = {}) {
164
272
  const state = el(doc, 'p', { class: 'pmw-chat__state', role: 'status' })
165
273
  const announcer = el(doc, 'p', { class: 'pmw-chat__sr', 'aria-live': 'polite' })
166
274
 
167
- const widget = (form) => el(doc, 'div', {
168
- class: 'cf-turnstile pmw-turnstile', 'data-sitekey': cfg.siteKey ?? '', 'data-action': 'chat', 'data-theme': 'light',
169
- 'data-language': lang, 'data-appearance': 'interaction-only', 'data-size': 'flexible',
170
- })
275
+ // A widget in its row (see « TURNSTILE » above): the box the chat renders explicitly, once.
276
+ const widget = (form, shown) => {
277
+ const row = el(doc, 'div', { class: 'pmw-chat__check' })
278
+ const inner = el(doc, 'div', { class: 'pmw-chat__check-in' })
279
+ inner.inert = true
280
+ const ask = (on) => { row.toggleAttribute('data-ask', on); inner.inert = !on; follow() }
281
+ const w = el(doc, 'div', { class: 'pmw-chat__turnstile' })
282
+ const box = {
283
+ el: w, form, shown, ask, asking: () => row.hasAttribute('data-ask'),
284
+ params: {
285
+ sitekey: cfg.siteKey ?? '', action: 'chat', theme: 'light', language: lang,
286
+ appearance: 'interaction-only', size: 'flexible', 'refresh-expired': 'auto',
287
+ callback: () => { box.retried = false; box.failed = false; ask(false) },
288
+ 'before-interactive-callback': () => ask(true),
289
+ },
290
+ }
291
+ boxes.push(box)
292
+ inner.append(w)
293
+ row.append(inner)
294
+ return row
295
+ }
171
296
  const composer = el(doc, 'form', { class: 'pmw-chat__composer', novalidate: true })
172
297
  const input = el(doc, 'textarea', { id: 'pmw-chat-input', name: 'message', rows: '1', maxlength: String(MAX_TEXT), placeholder: s.messageLabel, autocomplete: 'off', disabled: demo, 'aria-describedby': demo ? 'pmw-chat-demo-hint' : null })
173
298
  const send = el(doc, 'button', { type: 'submit', class: 'pmw-chat__send', disabled: demo }, s.send)
174
299
  // One rounded field holds the photo button, the text and the send button; hints and errors go under it.
175
300
  const box = el(doc, 'div', { class: 'pmw-chat__box' })
176
301
  box.append(el(doc, 'label', { class: 'pmw-chat__sr', for: 'pmw-chat-input' }, s.messageLabel), input, send)
302
+ // The check row sits ABOVE the field: the composer is pinned to the panel's bottom, so the row
303
+ // opening or collapsing never moves the field the visitor is typing in.
304
+ if (!demo) composer.append(widget(composer, () => true))
177
305
  composer.append(box)
178
306
  if (demo) composer.append(el(doc, 'p', { class: 'pmw-chat__hint', id: 'pmw-chat-demo-hint' }, s.demoHint))
179
- else composer.append(widget(composer))
180
307
  let picker = null
181
308
  const photoError = el(doc, 'p', { class: 'pmw-chat__error pmw-chat__photo-error', role: 'alert' })
182
309
  if (cfg.photos === true) {
@@ -210,16 +337,61 @@ export function mountChat(root, deps = {}) {
210
337
  field('need', 'textarea', { rows: '3', maxlength: '2000' }),
211
338
  fbError, el(doc, 'button', { type: 'submit', class: 'pmw-chat__send' }, s.fallbackSend),
212
339
  )
213
- if (!demo) fallback.append(widget(fallback))
340
+ if (!demo) fallback.insertBefore(widget(fallback, () => !fallback.hidden), fbError)
214
341
  const body = el(doc, 'div', { class: 'pmw-chat__body' })
215
342
  body.append(log, state, ended, fallback)
216
343
  panel.append(head, body, composer, announcer)
217
344
  root.append(panel)
345
+ turnstile ??= defaultTurnstile(win, boxes)
346
+
347
+ // ---- following the thread ---------------------------------------------------------------
348
+ // PINNED (operator, 2026-11-13): while the visitor is at the bottom (within PIN_SLACK), every
349
+ // change that adds height (a streamed fragment, the stored message replacing it, the typing dots,
350
+ // a photo once decoded, the takeover line, the end block, the composer growing or its check row
351
+ // opening) keeps the thread at the bottom. A visitor who scrolled UP to read stays where they are;
352
+ // reaching the bottom again pins it again. Sending a message is explicit: it always pins. The
353
+ // scroll is smooth, one per animation frame however many fragments land in it (a stream would
354
+ // otherwise stack dozens), and instant under prefers-reduced-motion and when the panel opens.
355
+ // Our own scrolls only ever move down, so a scroll that moves UP away from the bottom is the
356
+ // visitor's.
357
+ let pinned = true
358
+ let lastTop = 0
359
+ let followAt = 0
360
+ const gap = () => body.scrollHeight - body.scrollTop - body.clientHeight
361
+ body.addEventListener('scroll', () => {
362
+ const t = body.scrollTop
363
+ if (gap() <= PIN_SLACK) pinned = true
364
+ else if (t < lastTop) pinned = false
365
+ lastTop = t
366
+ }, { passive: true })
367
+ const nextFrame = (f) => (win.requestAnimationFrame ? win.requestAnimationFrame(f) : win.setTimeout(f, 16))
368
+ function follow(force = false, instant = false) {
369
+ if (force) pinned = true
370
+ if (!pinned || followAt) return
371
+ followAt = nextFrame(() => {
372
+ followAt = 0
373
+ if (!pinned) return
374
+ const still = instant || !!win.matchMedia?.('(prefers-reduced-motion: reduce)').matches
375
+ body.scrollTo({ top: body.scrollHeight, behavior: still ? 'instant' : 'smooth' })
376
+ })
377
+ }
378
+ if (typeof win.ResizeObserver === 'function') {
379
+ const ro = new win.ResizeObserver(() => follow())
380
+ ro.observe(log)
381
+ ro.observe(composer)
382
+ }
218
383
 
219
384
  // ---- state ------------------------------------------------------------------------------
220
385
  let session = null
221
386
  if (!demo) try { session = JSON.parse(win.sessionStorage.getItem(SESSION_KEY) ?? 'null') } catch { session = null }
387
+ // TWO HOURS WITHOUT A MESSAGE END IT (operator, 2026-11-13): a tab left open overnight starts a
388
+ // new conversation; pmeweb-chat closes the old one on its side too. `at` is the last message seen.
389
+ if (session && typeof session.at === 'number' && !(Date.now() - session.at < IDLE_MS)) {
390
+ session = null
391
+ try { win.sessionStorage.removeItem(SESSION_KEY) } catch { /* this tab only */ }
392
+ }
222
393
  const save = () => { try { win.sessionStorage.setItem(SESSION_KEY, JSON.stringify(session)) } catch { /* this tab only */ } }
394
+ const touch = () => { if (session) { session.at = Date.now(); save() } }
223
395
  let ws = null
224
396
  let attempt = 0
225
397
  let retryTimer = null
@@ -269,18 +441,37 @@ export function mountChat(root, deps = {}) {
269
441
  // scan back stops at a line already there or at an owner message, so one takeover gets one line
270
442
  // however often the frames repeat it; the bot answering again and a person taking over again
271
443
  // gets another.
272
- function relay(at, by) {
273
- let x = at ? at.previousElementSibling : (typing.parentNode === log ? typing.previousElementSibling : log.lastElementChild)
274
- for (; x; x = x.previousElementSibling) {
275
- if (x.classList.contains('pmw-chat__relay') || x.classList.contains('pmw-chat__msg--owner')) return
276
- if (x.classList.contains('pmw-chat__msg--bot')) break
277
- }
278
- const text = by ? fill(s.relay, { name: by }) : s.relayGeneric
279
- const sep = el(doc, 'li', { class: 'pmw-chat__relay', role: 'separator', 'aria-label': text })
444
+ //
445
+ // THE HAND-BACK (operator, 2026-11-13) is its mirror: where the owner gives the conversation back
446
+ // (« Rendre à l’assistant »), one line « L’assistant virtuel a repris la conversation », always
447
+ // the « assistant virtuel » wording (never the bot's name alone: it must not pass for a person).
448
+ // It goes before the first bot message that follows a person (live, and on replay), or at the end
449
+ // when the mode turns back to bot before the bot speaks. Its scan back stops at a hand-back line
450
+ // already there or at a bot message (nothing to mark), and inserts only past an owner message or a
451
+ // takeover line: a conversation the bot never left gets none. A takeover after it gets its own line.
452
+ const lastLine = () => (typing.parentNode === log ? typing.previousElementSibling : log.lastElementChild)
453
+ const isA = (x, c) => x.classList.contains(c)
454
+ function separator(cls, dotCls, text, at) {
455
+ const sep = el(doc, 'li', { class: cls, role: 'separator', 'aria-label': text })
280
456
  const label = el(doc, 'span', { class: 'pmw-chat__relay-label', 'aria-hidden': 'true' })
281
- label.append(el(doc, 'span', { class: 'pmw-chat__relay-dot' }), text)
457
+ label.append(el(doc, 'span', { class: dotCls }), text)
282
458
  sep.append(label)
283
459
  log.insertBefore(sep, at ?? (typing.parentNode === log ? typing : null))
460
+ follow()
461
+ }
462
+ function relay(at, by) {
463
+ for (let x = at ? at.previousElementSibling : lastLine(); x; x = x.previousElementSibling) {
464
+ if (isA(x, 'pmw-chat__relay') || isA(x, 'pmw-chat__msg--owner')) return
465
+ if (isA(x, 'pmw-chat__msg--bot') || isA(x, 'pmw-chat__handback')) break
466
+ }
467
+ separator('pmw-chat__relay', 'pmw-chat__relay-dot', by ? fill(s.relay, { name: by }) : s.relayGeneric, at)
468
+ }
469
+ function handback(at) {
470
+ for (let x = at ? at.previousElementSibling : lastLine(); ; x = x.previousElementSibling) {
471
+ if (!x || isA(x, 'pmw-chat__handback') || isA(x, 'pmw-chat__msg--bot')) return
472
+ if (isA(x, 'pmw-chat__relay') || isA(x, 'pmw-chat__msg--owner')) break
473
+ }
474
+ separator('pmw-chat__handback', 'pmw-chat__relay-dot pmw-chat__relay-dot--back', s.handback, at)
284
475
  }
285
476
  const find = (id) => [...log.querySelectorAll('li[data-id]')].find((li) => li.dataset.id === id)
286
477
  const setText = (li, m, text) => {
@@ -292,6 +483,14 @@ export function mountChat(root, deps = {}) {
292
483
  typing.hidden = true
293
484
  win.clearTimeout(typingTimer)
294
485
  }
486
+ /** The dots, until a reply hides them; `ms` also hides them after that long without one. */
487
+ function showTyping(ms) {
488
+ typing.hidden = false
489
+ log.append(typing)
490
+ win.clearTimeout(typingTimer)
491
+ if (ms) typingTimer = win.setTimeout(hideTyping, ms)
492
+ follow()
493
+ }
295
494
  function render(m, { announce }) {
296
495
  if (seen.has(m.seq)) return
297
496
  seen.add(m.seq)
@@ -300,6 +499,7 @@ export function mountChat(root, deps = {}) {
300
499
  if (!li) {
301
500
  li = line(m.id, m.role)
302
501
  if (m.role === 'owner') relay(li, m.by || ownerName)
502
+ else if (m.role === 'bot') handback(li)
303
503
  }
304
504
  li.classList.remove('is-streaming', 'is-pending', 'is-failed')
305
505
  li.querySelector('.pmw-chat__retry')?.remove()
@@ -313,20 +513,20 @@ export function mountChat(root, deps = {}) {
313
513
  if (announce) announcer.textContent = fill(s.said, { name: speaker(m), text: m.text })
314
514
  if (panel.hidden) root.setAttribute('data-unread', '')
315
515
  }
316
- scroll()
516
+ follow()
317
517
  }
318
518
  function delta(id, text) {
319
519
  hideTyping()
320
520
  let li = find(id)
321
521
  if (!li) {
322
522
  li = line(id, 'bot')
523
+ handback(li)
323
524
  li.classList.add('is-streaming')
324
525
  setText(li, { role: 'bot' }, '')
325
526
  }
326
527
  li.lastChild.textContent += text
327
- scroll()
528
+ follow()
328
529
  }
329
- const scroll = () => { log.scrollTop = log.scrollHeight }
330
530
  if (demo) {
331
531
  setText(line('demo-question', 'visitor'), { role: 'visitor' }, demoQuestion(opener, lang))
332
532
  const answer = line('demo-answer', 'bot')
@@ -375,6 +575,8 @@ export function mountChat(root, deps = {}) {
375
575
  ws = sock
376
576
  sock.onopen = () => {
377
577
  attempt = 0
578
+ // A first start: the dots stay until the reply, for TYPING_SHOW_MS at most, like the service's.
579
+ if (slowTimer) { notSlow(); showTyping(TYPING_SHOW_MS) }
378
580
  showState('')
379
581
  sock.send(JSON.stringify({ type: 'hello', since: lastSeq }))
380
582
  for (const entry of pending.values()) transmit(entry)
@@ -389,6 +591,7 @@ export function mountChat(root, deps = {}) {
389
591
  for (const entry of pending.values()) if (entry.tries >= MAX_TRIES) failed(entry)
390
592
  if (e.code >= 4000 && e.code < 5000) return refused(e.code)
391
593
  if (over) return
594
+ notSlow()
392
595
  showState(s.reconnecting)
393
596
  const wait = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)]
394
597
  attempt++
@@ -408,12 +611,14 @@ export function mountChat(root, deps = {}) {
408
611
  ended.hidden = false
409
612
  composer.hidden = true
410
613
  for (const entry of pending.values()) failed(entry)
614
+ follow()
411
615
  }
412
616
  function status(m) {
413
617
  mode = m.mode === 'human' ? 'human' : 'bot'
414
618
  ownerName = typeof m.ownerName === 'string' ? m.ownerName : null
415
619
  header()
416
620
  if (mode === 'human') relay(null, ownerName)
621
+ else handback(null)
417
622
  if (m.closed) end(s.ended, true)
418
623
  }
419
624
  function frame(m) {
@@ -423,16 +628,13 @@ export function mountChat(root, deps = {}) {
423
628
  for (const x of m.messages ?? []) render(x, { announce: false })
424
629
  return status(m)
425
630
  case 'message':
631
+ touch()
426
632
  return render(m.msg, { announce: m.msg?.role !== 'visitor' })
427
633
  case 'delta':
428
634
  return delta(m.id, m.text)
429
635
  case 'typing':
430
636
  if (m.role === 'visitor') return
431
- typing.hidden = false
432
- log.append(typing)
433
- win.clearTimeout(typingTimer)
434
- typingTimer = win.setTimeout(hideTyping, TYPING_SHOW_MS)
435
- return scroll()
637
+ return showTyping(TYPING_SHOW_MS)
436
638
  case 'status':
437
639
  return status(m)
438
640
  case 'fallback':
@@ -453,9 +655,23 @@ export function mountChat(root, deps = {}) {
453
655
  }
454
656
 
455
657
  // ---- starting a conversation ------------------------------------------------------------
658
+ /** Forms whose Turnstile token was used: reset just before the next post, never right after. */
659
+ const spent = new Set()
456
660
  async function post(form, extra) {
457
661
  let token = ''
662
+ // A used token is replaced only when a NEW one is needed (a restart, the form, a retry): a reset
663
+ // right after a successful start made Cloudflare show its checkbox under a conversation that
664
+ // needs no check any more (messages go over the socket; operator, 2026-11-13).
665
+ if (spent.has(form)) {
666
+ spent.delete(form)
667
+ try { turnstile.reset(form) } catch { /* the token call below waits for a fresh one */ }
668
+ }
458
669
  try { token = await turnstile.token(form) } catch { token = '' }
670
+ // The box failed twice (« TURNSTILE » above): nothing to post; the next post starts a fresh one.
671
+ if (token === null) {
672
+ spent.add(form)
673
+ return { ok: false, error: s.turnstile, cap: false, turnstile: true }
674
+ }
459
675
  let res
460
676
  let data = null
461
677
  try {
@@ -468,23 +684,35 @@ export function mountChat(root, deps = {}) {
468
684
  } catch {
469
685
  res = null
470
686
  }
471
- try { turnstile.reset(form) } catch { /* a token is single-use; the next post asks again */ }
687
+ spent.add(form)
472
688
  if (res?.ok && data) return { ok: true, data }
473
689
  const error = res?.status === 403 ? s.turnstile : res?.status === 429 ? s.tooMany : null
474
- return { ok: false, error, cap: res?.status === 409 && data?.error === 'cap' }
690
+ return { ok: false, error, cap: res?.status === 409 && data?.error === 'cap', turnstile: res?.status === 403 }
475
691
  }
692
+ // THE FIRST START (operator, 2026-11-13) reads like any reply: the typing dots at once, no
693
+ // « Connexion… ». Only a start slower than SLOW_START_MS adds « Un instant… »; the socket's open
694
+ // clears it, a drop says « Connexion perdue, reconnexion… » as before. Nothing is started before
695
+ // the visitor writes: pmeweb-chat counts every /start toward the visitor's conversations of the day
696
+ // (src/ipLimit.ts there), so a panel opened and never used must not spend one. Only the token is
697
+ // prepared ahead (« TURNSTILE » above).
698
+ let slowTimer = null
699
+ const notSlow = () => { win.clearTimeout(slowTimer); slowTimer = null }
476
700
  function begin() {
477
701
  if (starting || session) return starting
478
- showState(s.connecting)
702
+ showState('')
703
+ showTyping()
704
+ slowTimer = win.setTimeout(() => showState(s.oneMoment), SLOW_START_MS)
479
705
  starting = post(composer, {}).then((r) => {
480
706
  starting = null
481
707
  if (r.ok && r.data.conversationId && r.data.visitorKey) {
482
- session = { c: r.data.conversationId, k: r.data.visitorKey, botName: typeof r.data.botName === 'string' ? r.data.botName : null }
708
+ session = { c: r.data.conversationId, k: r.data.visitorKey, botName: typeof r.data.botName === 'string' ? r.data.botName : null, at: Date.now() }
483
709
  save()
484
710
  header()
485
711
  connect()
486
712
  return
487
713
  }
714
+ notSlow()
715
+ hideTyping()
488
716
  for (const entry of pending.values()) failed(entry)
489
717
  if (r.error) return showState(r.error)
490
718
  showState('')
@@ -494,9 +722,13 @@ export function mountChat(root, deps = {}) {
494
722
  return starting
495
723
  }
496
724
  function showFallback(text) {
725
+ // Nobody is writing any more: the dots and « Un instant… » go with the form (operator, 2026-11-13).
726
+ hideTyping(); notSlow(); showState('')
497
727
  fbIntro.textContent = text
498
728
  fallback.hidden = false
499
729
  composer.hidden = true
730
+ // Its Turnstile box is rendered now that it has a size (« TURNSTILE » above).
731
+ if (!demo) turnstile.load()
500
732
  fallback.querySelector('input')?.focus()
501
733
  }
502
734
  fallback.addEventListener('submit', async (e) => {
@@ -508,7 +740,17 @@ export function mountChat(root, deps = {}) {
508
740
  return
509
741
  }
510
742
  fbError.textContent = ''
511
- const r = await post(fallback, { fallbackLead: fields })
743
+ // One request per press: the button waits for the answer (a second press made a second lead).
744
+ const btn = fallback.querySelector('button[type="submit"]')
745
+ if (btn.disabled) return
746
+ btn.disabled = true
747
+ // The conversation the visitor had joins the request, so the link shows its thread (1.3.2).
748
+ const extra = { fallbackLead: fields, ...(session?.c && session?.k ? { c: session.c, k: session.k } : {}) }
749
+ let r = await post(fallback, extra)
750
+ // A refused check is retried ONCE with a fresh token before the visitor sees anything: the
751
+ // first token can be one Cloudflare already redeemed or let expire (operator, 2026-11-13).
752
+ if (!r.ok && r.turnstile) r = await post(fallback, extra)
753
+ btn.disabled = false
512
754
  if (!r.ok) {
513
755
  fbError.textContent = r.error ?? s.unavailable
514
756
  return
@@ -532,9 +774,12 @@ export function mountChat(root, deps = {}) {
532
774
  const id = uuid()
533
775
  const li = line(id, 'visitor')
534
776
  li.classList.add('is-pending')
535
- li.insertBefore(el(doc, 'img', { class: 'pmw-chat__photo', src, alt: s.photoAlt }), li.lastChild)
777
+ const img = el(doc, 'img', { class: 'pmw-chat__photo', src, alt: s.photoAlt })
778
+ // Its height is known only once decoded: re-pin then (follow() is a no-op if they scrolled up).
779
+ img.addEventListener('load', () => follow(), { once: true })
780
+ li.insertBefore(img, li.lastChild)
536
781
  setText(li, { role: 'visitor' }, '')
537
- scroll()
782
+ follow(true)
538
783
  if (!session) await begin()
539
784
  let res = null
540
785
  if (session) {
@@ -580,7 +825,7 @@ export function mountChat(root, deps = {}) {
580
825
  entry.li.classList.add('is-pending')
581
826
  setText(entry.li, { role: 'visitor' }, entry.text)
582
827
  pending.set(entry.id, entry)
583
- scroll()
828
+ follow(true)
584
829
  if (!session) begin()
585
830
  else if (isOpen()) transmit(entry)
586
831
  else connect()
@@ -616,7 +861,9 @@ export function mountChat(root, deps = {}) {
616
861
  mode = 'bot'
617
862
  ownerName = null
618
863
  header()
619
- for (const li of [...log.querySelectorAll('li[data-id], .pmw-chat__relay')]) li.remove()
864
+ for (const li of [...log.querySelectorAll('li[data-id], .pmw-chat__relay, .pmw-chat__handback')]) li.remove()
865
+ hideTyping()
866
+ notSlow()
620
867
  ended.hidden = true
621
868
  composer.hidden = false
622
869
  input.focus()
@@ -635,23 +882,33 @@ export function mountChat(root, deps = {}) {
635
882
  if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus() }
636
883
  else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus() }
637
884
  })
638
- closeBtn.addEventListener('click', () => close())
885
+ // A mouse or touch close (click.detail > 0) does not park focus on the bubble: a focused bubble
886
+ // never steps aside for the footer (operator, 2026-11-13). Keyboard closes (Enter, Escape) still do.
887
+ closeBtn.addEventListener('click', (e) => close({ keyboard: e.detail === 0 }))
639
888
 
640
889
  function show() {
641
890
  if (!demo) turnstile.load()
642
891
  panel.hidden = false
892
+ panel.inert = false
643
893
  root.setAttribute('data-open', '')
644
894
  root.removeAttribute('data-unread')
645
895
  if (session) connect()
646
896
  ;(demo ? closeBtn : composer.hidden ? (fallback.hidden ? restart : fallback.querySelector('input')) : input)?.focus()
647
- scroll()
897
+ follow(true, true)
648
898
  }
649
- function close() {
899
+ function close({ keyboard = true } = {}) {
650
900
  panel.hidden = true
901
+ panel.inert = true
651
902
  root.removeAttribute('data-open')
652
- bubble?.focus()
903
+ if (keyboard) bubble?.focus()
904
+ // The trigger re-checks the footer on a focusout (trigger.js sync): tell it the panel is shut, so
905
+ // the bubble steps aside at once when the footer is already in view.
906
+ root.dispatchEvent(new win.FocusEvent('focusout', { relatedTarget: keyboard ? bubble : null }))
653
907
  }
654
908
 
909
+ // The early start (« TURNSTILE » above). Off the real hosts there is nothing to start.
910
+ if (!demo) onApproach(root, () => turnstile.load(), { win, settleMs: WARM_MS })
911
+
655
912
  root.pmwChat = { open: show, close, panel }
656
913
  return root.pmwChat
657
914
  }
package/src/worker.js CHANGED
@@ -32,7 +32,7 @@ import { verifyTurnstile } from '@pmeweb/turnstile/worker'
32
32
  import { RPC_HEADER, RPC_VERSION } from './protocol.js'
33
33
 
34
34
  /** This package's version, reported by the health route (a test holds it equal to package.json). */
35
- export const VERSION = '1.3.0'
35
+ export const VERSION = '1.3.2'
36
36
  export { RPC_VERSION }
37
37
 
38
38
  const SERVICE = 'https://pmeweb-chat.internal'
@@ -117,6 +117,12 @@ async function start(request, url, env, { config, lang }) {
117
117
  if (config?.sms === 'voipms') body.sms = 'voipms'
118
118
  if (config?.photos === true) body.photos = true
119
119
  if (lead) body.fallbackLead = lead
120
+ // The conversation the visitor already had, so the form's request joins its thread (1.3.2); the
121
+ // service checks the key, as it does for the socket.
122
+ if (lead && typeof b.c === 'string' && /^[0-9a-f-]{36}$/i.test(b.c) && typeof b.k === 'string' && /^[A-Za-z0-9_-]{16,64}$/.test(b.k)) {
123
+ body.conversationId = b.c
124
+ body.visitorKey = b.k
125
+ }
120
126
 
121
127
  const headers = { 'content-type': 'application/json', [RPC_HEADER]: String(RPC_VERSION) }
122
128
  const ip = request.headers.get('cf-connecting-ip')