@pmeweb/plugin-chat 1.1.0 → 1.2.0

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
@@ -34,17 +34,16 @@ package's, approved once.
34
34
  ```jsonc
35
35
  "services": [{ "binding": "CHAT", "service": "pmeweb-chat", "entrypoint": "ChatApi" }],
36
36
  "ratelimits": [
37
- { "name": "CHAT_LIMIT", "namespace_id": "<unused on the account>", "simple": { "limit": 20, "period": 60 } },
38
- { "name": "CHAT_START_LIMIT", "namespace_id": "<another unused on the account>", "simple": { "limit": 3, "period": 60 } }
37
+ { "name": "CHAT_LIMIT", "namespace_id": "<unused on the account>", "simple": { "limit": 20, "period": 60 } }
39
38
  ]
40
39
  ```
41
40
 
42
- Both count per IP, not messages. `CHAT_START_LIMIT` counts conversation starts (3 a minute);
43
- `CHAT_LIMIT` counts socket (re)connections, photo uploads and the contact form's posts. pmeweb-chat
44
- caps the rest: 15 visitor messages per conversation, 10 conversations per visitor IP per Quebec
45
- day (it receives the IP from `handleChat` in `x-pmw-visitor-ip`), and the site's daily spend.
46
- **Updating from 1.0.0**: add the `CHAT_START_LIMIT` entry and `CHAT_START_LIMIT: RateLimit` to
47
- `Env` (check-plugins refuses the update without it); nothing else changes on the site.
41
+ It counts per IP, not messages: starts, socket (re)connections, photo uploads and the contact
42
+ form's posts. pmeweb-chat applies the GLOBAL limits (portal admin, « Assistant virtuel »):
43
+ messages per conversation, conversations per visitor per Quebec day and starts per minute (it
44
+ receives the IP from `handleChat` in `x-pmw-visitor-ip`), and the daily cap per site.
45
+ **Updating from 1.1.0**: remove the `CHAT_START_LIMIT` entry and its `Env` line; nothing else
46
+ changes on the site. From 1.0.0, nothing to add.
48
47
  4. [ASK] `npx wrangler secret put TURNSTILE_SECRET` (skip when the contact form already set it;
49
48
  `npx wrangler secret list` to check).
50
49
  5. **Worker** (`worker/index.ts`), before `pluginArtifacts` and the assets:
@@ -65,7 +64,7 @@ package's, approved once.
65
64
  CSP: `mergeCsp(policy, chatCsp())` (Turnstile's script and frame; the socket is same-origin).
66
65
  With `"photos": true` the panel's thumbnail is a `data:` URL: `img-src` keeps `data:` (the
67
66
  boilerplate's does).
68
- `Env` gains `CHAT: Fetcher`, `CHAT_LIMIT: RateLimit` and `CHAT_START_LIMIT: RateLimit`.
67
+ `Env` gains `CHAT: Fetcher` and `CHAT_LIMIT: RateLimit`.
69
68
  6. **Openers**, written by Claude Code at install, in every dictionary (`src/i18n/<lang>.ts`),
70
69
  strings only, OQLF typography, `docs/AVOIDING_AI_TELLS.md`:
71
70
 
@@ -120,7 +119,7 @@ package's, approved once.
120
119
  10. **Not in `pmeweb.json` `forms`**: the chat's Turnstile widget renders only once the panel is
121
120
  open, so the portal's form-health browser check would find nothing on a page load.
122
121
  11. `npm run build && node ../../a11y-tools/check-plugins.mjs .` until PASS; `npm run check`.
123
- 12. **Portal** (« Modules › Assistant virtuel »): PmeWeb sets `alertEmails` and `dailyCap`
122
+ 12. **Portal** (« Modules › Assistant virtuel »): PmeWeb sets `alertEmails`
124
123
  (`alertEmails` is needed before the switch goes on: pmeweb-chat refuses a site without it), `alertPhone` if texts are on, and
125
124
  « Consignes » with the client; the client may set `ownerName`, and changes the face, the icon's
126
125
  colour and the name (`botName`) in « Contenu › Modules › Assistant virtuel › Apparence »
package/README.md CHANGED
@@ -7,7 +7,7 @@ email (and text), and lets the owner take over live.
7
7
  Three pieces:
8
8
 
9
9
  - **This package**, on the site: the bubble and the opener (plain HTML, `Chat.astro`), an inline
10
- trigger under 2 KB, the panel loaded on the first touch (`dist/widget.js`), the site Worker's
10
+ trigger under 2.3 KB (`inlineBudget` 2304), the panel loaded on the first touch (`dist/widget.js`), the site Worker's
11
11
  `/api/chat/start`, `/api/chat/ws` and `/api/chat/photo` (`handleChat`), `health`, `cspHosts`, and the build-time
12
12
  context file (`@pmeweb/plugin-chat/build`).
13
13
  - **`pmeweb-chat`** (private): one Durable Object per conversation, the model (Gemma 4 on Workers
@@ -38,9 +38,21 @@ Install it with the `install-plugin` skill (`INSTALL.md`).
38
38
  PmeWeb's, set at install.
39
39
  - **Nothing loads with the page.** The bubble is HTML and CSS; the panel, Turnstile and the socket
40
40
  arrive when the visitor presses, hovers, focuses or types on it.
41
- - **Openers.** One line per page, from the site's dictionary (`chat.openers`, `chat.defaultOpener`).
41
+ - **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.
42
42
  Shown once per visit (20 s, 30 % of the page, or exit intent with a mouse), never while the panel
43
- is open. The same line greets the visitor in the panel.
43
+ is open, never while the footer is in view (it waits for the next of those triggers). On a phone
44
+ (under 640 px) it fades out by itself 8 s after it appears. The same line greets the visitor in
45
+ the panel. The « once per visit » is a `pmw-chat-opener` flag in `sessionStorage` (a UI flag, no
46
+ personal information; storage blocked: once per page).
47
+ - **On a phone** (under 640 px, where the panel goes full screen) the bubble is a 56 px round
48
+ button with the face alone; « Assistant virtuel » stays in the markup, visually hidden, and the
49
+ button's `aria-label` still starts with it. From 640 px it is the pill with the text.
50
+ - **Out of the footer's way** (real hosts only). While the page's last `footer` element is in view
51
+ (IntersectionObserver), the bubble and the opener fade out (opacity and an 8 px slide, 0.2 s,
52
+ then `visibility: hidden`) and the root is `inert`; they come back when it leaves. A site whose
53
+ footer is not a `footer`, or that wants another zone kept clear, marks it `data-pmw-chat-avoid`
54
+ (the first one wins over the footer). Never while the panel is open or while focus is in the
55
+ bubble or the opener (a focusout re-checks). Reduced motion: hidden and shown at once.
44
56
  - **Never passing for a person.** « Assistant virtuel » is always in text; the green dot appears
45
57
  only when the owner has taken over, with the owner's name.
46
58
  - **The socket.** A drop (1006, a forced deploy of pmeweb-chat) reconnects with backoff 1 to 30 s
@@ -50,23 +62,43 @@ Install it with the `install-plugin` skill (`INSTALL.md`).
50
62
  conversations of the day used up (pmeweb-chat answers the start 409 `cap`), the model down, or
51
63
  the start refused: a three-field contact form that posts to `/api/chat/start` with `fallbackLead`
52
64
  (never counted against the visitor's 10, and on the looser `CHAT_LIMIT`).
53
- - **Limits.** Per IP: `CHAT_START_LIMIT` (3 conversation starts a minute) and `CHAT_LIMIT` (20 a
54
- minute: socket reconnections, photos, the contact form). The site Worker hands the visitor's IP
65
+ - **Limits.** GLOBAL since 1.2.0, the same for every site, edited in the portal admin
66
+ (« Assistant virtuel ») and applied by pmeweb-chat: messages per conversation, conversations per
67
+ visitor per day, conversation starts per minute, the daily cap per site. On the site, only
68
+ `CHAT_LIMIT` (20 a minute per IP: starts, socket reconnections, photos, the contact form). The site Worker hands the visitor's IP
55
69
  to pmeweb-chat in `x-pmw-visitor-ip` (1.1.0); pmeweb-chat keeps only a daily hash of it to count
56
- conversations per day. Health reports `limit` and `startLimit`.
70
+ conversations per day. Health reports `limit`.
57
71
  - **Photos** (`"photos": true`). Up to 3 per conversation, shrunk in the browser to a 1600 px JPEG
58
72
  (the canvas drops the EXIF, GPS included), posted to `/api/chat/photo` (rate-limited like a
59
73
  message, 5 MB cap), re-encoded by pmeweb-chat and kept in a private bucket. The visitor sees the
60
74
  local copy; the owner sees them through the portal. The bot does not read them.
61
75
  - **The goal** `chat-lead` fires once per conversation (`window.pmw?.event`).
62
76
 
77
+ ## Above a sticky bar
78
+
79
+ The bubble, the opener and the desktop panel all stand `--pmw-chat-bottom` (16px) above the bottom
80
+ edge, plus the safe area. A site with its own sticky bar at the bottom lifts them while the bar is
81
+ visible: it sets the variable to the bar's height plus 12px, on `<html>`, `<body>` or `#pmw-chat`,
82
+ from whatever already says the bar is up (a class, an attribute). The package eases the change
83
+ (0.3 s, none under reduced motion). The phone panel is full screen and covers the bar on purpose.
84
+
85
+ ```css
86
+ /* The bar is 61px tall (the safe area is already added by the package). */
87
+ @media (max-width: 899px) {
88
+ body:has(#sticky-cta:not(.translate-y-full)) { --pmw-chat-bottom: 73px; }
89
+ }
90
+ ```
91
+
92
+ Nothing site-specific lives in the package: the selector is the site's.
93
+
63
94
  ## Markup contract
64
95
 
65
96
  A site may write its own markup carrying these and render the inline trigger itself:
66
97
 
67
98
  | Selector | Role |
68
99
  |---|---|
69
- | `#pmw-chat` | the root, `hidden` until the trigger finds a real host; `data-open` while the panel is open, `data-unread` when a reply arrived with the panel closed |
100
+ | `#pmw-chat` | the root, `hidden` until the trigger runs; `data-open` while the panel is open, `data-unread` when a reply arrived with the panel closed, `inert` while it steps out of the footer's way |
101
+ | `[data-pmw-chat-avoid]` | anywhere on the page: the zone the bubble fades away from (default: the page's last `footer`) |
70
102
  | `[data-pmw-chat-opener]` | on the root: this page's opener text |
71
103
  | `[data-pmw-lazy]` | on the root: the URL of `dist/widget.js` (`?url&no-inline`) |
72
104
  | `[data-pmw-chat-open]` | the bubble and the opener's text: a click opens the panel |
@@ -84,10 +116,13 @@ the package defaults). For a `sans-visage` icon the circle is `config.avatarBg`
84
116
  (#14181f without one), whichever contrasts more; a circle under 3:1 against the page `background`
85
117
  or the `surface` gets a 1.5 px ring in `ink`. No text says so.
86
118
 
87
- The panel: the header, the visitor's bubbles and the send button in `primary`, their text white or
88
- `ink` (#14181f when the ink is too light), at 4.5:1 or the default navy stays; the panel and the
89
- bubble in `surface` with `ink` text (at 4.5:1 or the default white panel stays), the bot's bubbles
90
- in `background`, and muted text, links, the focus ring and errors re-derived to keep their minimum.
119
+ The panel: the visitor's bubbles and the send button in `primary`, their text white or `ink`
120
+ (#14181f when the ink is too light), at 4.5:1 or the default navy stays; the panel, its header,
121
+ the bot's bubbles and the launcher in `surface` with `ink` text (at 4.5:1 or the default white
122
+ panel stays); the thread behind the bubbles a 4 % tint of `accent` (else `primary`) over the
123
+ surface; a person who took over in an 8 % tint of the accent; and muted text, links, the live green,
124
+ the focus ring and errors re-derived to keep their minimum on both the surface and the thread.
125
+ Where a person takes over, the thread shows « {name} a pris le relais » (an `li role="separator"`).
91
126
  The fields stay white with their own text colour. The values land in one
92
127
  `<style>:where(:root){...}</style>` beside the root: zero specificity, so a site's own
93
128
  `--pmw-chat-*` value still wins.
@@ -98,9 +133,11 @@ Never declared by the package: each use carries its default, so a site sets any
98
133
  `--pmw-chat-accent` (#0a1b3d) and `--pmw-chat-accent-fg` (#ffffff), `--pmw-chat-avatar-bg`,
99
134
  `--pmw-chat-avatar-fg` and `--pmw-chat-avatar-ring` (the sans-visage circle), `--pmw-chat-fg` (#1f2328),
100
135
  `--pmw-chat-muted` (#4b5563), `--pmw-chat-bubble-bg` and `--pmw-chat-panel-bg` (#ffffff),
101
- `--pmw-chat-other-bg` (#f1f3f6), `--pmw-chat-border` (#c9ced6), `--pmw-chat-field-border`
136
+ `--pmw-chat-other-bg` (#ffffff, the bot's bubbles), `--pmw-chat-thread-bg` (#f7f8fb),
137
+ `--pmw-chat-owner-bg` (#eef2fa) and `--pmw-chat-owner-border` (#d5ddef), `--pmw-chat-line` (#e6e9f0)
138
+ and `--pmw-chat-rule` (#cfd5e1) (decoration), `--pmw-chat-border` (#c9ced6), `--pmw-chat-field-border`
102
139
  (#7d8590), `--pmw-chat-field-fg` (#1f2328, the text in the white fields), `--pmw-chat-focus`, `--pmw-chat-link`, `--pmw-chat-error` (#b42318), `--pmw-chat-live`
103
- (#3fb950), `--pmw-chat-font`, `--pmw-chat-right` and `--pmw-chat-bottom` (16px). Check every pair a
140
+ (#1a7f37, the « Vous répond en direct » text and the dots), `--pmw-chat-font`, `--pmw-chat-right` and `--pmw-chat-bottom` (16px). Check every pair a
104
141
  site changes with `a11y-tools/contrast.mjs`.
105
142
 
106
143
  ## What goes wrong in silence
@@ -112,7 +149,7 @@ site changes with `a11y-tools/contrast.mjs`.
112
149
  - `enabled` not exactly `true` (`"true"` in quotes, a typo): the chat is off, nothing on the site.
113
150
  - `@pmeweb/consent` older than 1.1.0: the privacy section prints while the chat is off
114
151
  (check-plugins refuses it).
115
- - Missing `alertEmails` or `dailyCap` in the portal: pmeweb-chat answers `not_configured`; health
152
+ - Missing `alertEmails` in the portal: pmeweb-chat answers `not_configured`; health
116
153
  says `settings: false`.
117
154
  - The context file absent from `dist/` or not served signed: the bot falls back to the contact form
118
155
  on every message.
@@ -122,7 +159,7 @@ site changes with `a11y-tools/contrast.mjs`.
122
159
  `config.avatar`, one of `src/faces.js` (the manifest's pattern is copied from it):
123
160
 
124
161
  - `photos-1` to `photos-5`, `illustration-1` to `illustration-5`: faces generated for PmeWeb (not
125
- real people), 96 x 96 WebP (about 1.5 KB) for the 48 px bubble at 2x; the panel header (40 px)
162
+ real people), 96 x 96 WebP (about 1.5 KB) for the 48 px bubble at 2x; the panel header (44 px)
126
163
  reuses the same file.
127
164
  - `sans-visage-1` to `sans-visage-5`: a speech-bubble glyph drawn inline in `currentColor` (no
128
165
  request). 3 to 5 are Lucide icons (ISC, `LICENSE-LUCIDE`).
@@ -1 +1 @@
1
- (()=>{function b({doc:s=document,hostname:n=globalThis.location?.hostname??""}={}){let c=s.getElementById("pmw-hosts");if(!c)return!1;let a;try{a=JSON.parse(c.textContent??"")}catch{return!1}if(!Array.isArray(a))return!1;let i=String(n).toLowerCase();return a.some(t=>typeof t=="string"&&t.toLowerCase()===i)}var S=["pointerdown","pointerenter","focusin","keydown"];function T(s,n,{events:c=S}={}){let a=!1,i=()=>{if(!a){a=!0;for(let t of c)s.removeEventListener(t,i);n()}};for(let t of c)s.addEventListener(t,i,{passive:!0});return{run:i}}var x="pmw-chat-opener",O=2e4,w=.3;function m({doc:s=document,win:n=window,hostname:c=n.location.hostname,importLazy:a=t=>import(t),onInteraction:i=T}={}){let t=s.getElementById("pmw-chat");if(!t)return null;t.hidden=!1;let f=t.querySelector(".pmw-chat__teaser"),l=null,u=()=>l??(l=a(t.dataset.pmwLazy).then(e=>e.mountChat(t),e=>{throw l=null,e}));i(t,()=>u().catch(()=>{}));let h=b({doc:s,hostname:c});if(!h){let e=()=>{let o=new URLSearchParams(n.location.hash.slice(1)).get("pmw-chat");o==="panel"&&!t.hasAttribute("data-open")?u().then(r=>r.open(),()=>{}):o==="bubble"&&l&&l.then(r=>r.close(),()=>{})};e(),n.addEventListener("hashchange",e)}let E=n.sessionStorage,d=!1;try{d=!!E?.getItem(x)}catch{}let v=[],y=(e,o,r)=>{e.addEventListener(o,r,{passive:!0}),v.push([e,o,r])},L,g=()=>{d=!0;try{E?.setItem(x,"1")}catch{}n.clearTimeout(L);for(let[e,o,r]of v)e.removeEventListener(o,r)},p=()=>{d||!f||t.hasAttribute("data-open")||(g(),f.hidden=!1)};return t.addEventListener("click",e=>{let o=e.target.closest?.("[data-pmw-chat-open],[data-pmw-chat-dismiss]");o&&(f&&(f.hidden=!0),g(),o.hasAttribute("data-pmw-chat-open")&&u().then(r=>r.open(),()=>{}))}),!d&&f&&h&&(L=n.setTimeout(p,O),y(n,"scroll",()=>{let e=s.documentElement.scrollHeight-n.innerHeight;e>0&&n.scrollY/e>=w&&p()}),n.matchMedia?.("(pointer: fine)").matches&&y(s,"mouseout",e=>{!e.relatedTarget&&e.clientY<=0&&p()})),{load:u,show:p}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>m()):m();})();
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();})();
package/dist/widget.js CHANGED
@@ -1,2 +1,2 @@
1
- var Ue=`.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 - 32px));border:1px solid var(--pmw-chat-border, #c9ced6);border-radius:12px;overflow:hidden;box-shadow:0 12px 36px #00000038}}.pmw-chat__panel button{font:inherit;cursor:pointer}.pmw-chat__panel :focus-visible{outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}.pmw-chat__head :focus-visible{outline-color:var(--pmw-chat-accent-fg, #ffffff)}.pmw-chat__head{display:flex;align-items:center;gap:10px;padding:10px 8px 10px 12px;background:var(--pmw-chat-accent, #0a1b3d);color:var(--pmw-chat-accent-fg, #ffffff)}.pmw-chat__head .pmw-chat__avatar{width:40px;height:40px;box-shadow:none;background:var(--pmw-chat-avatar-bg, var(--pmw-chat-accent-fg, #ffffff));color:var(--pmw-chat-avatar-fg, var(--pmw-chat-accent, #0a1b3d))}.pmw-chat__head .pmw-chat__avatar img{width:40px;height:40px}.pmw-chat__who{flex:1;min-width:0}.pmw-chat__name{margin:0;font-weight:600;font-size:16px}.pmw-chat__role{margin:0;font-size:14px}.pmw-chat__dot{flex:none;width:10px;height:10px;border-radius:50%;background:var(--pmw-chat-live, #3fb950);box-shadow:0 0 0 2px var(--pmw-chat-accent, #0a1b3d)}.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:none;color:inherit}.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:10px 12px}.pmw-chat__notice{margin:0 0 8px;font-size:14px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__notice a{color:var(--pmw-chat-link, #0a1b3d);text-decoration:underline;text-underline-offset:2px}.pmw-chat__log{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}.pmw-chat__msg{max-width:85%;padding:8px 12px;border-radius:12px;white-space:pre-wrap;overflow-wrap:anywhere}.pmw-chat__msg--bot,.pmw-chat__msg--owner{align-self:flex-start;background:var(--pmw-chat-other-bg, #f1f3f6);color:var(--pmw-chat-fg, #1f2328);border-bottom-left-radius:4px}.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:4px}.pmw-chat__msg.is-pending{opacity:.75}.pmw-chat__msg.is-failed{outline:2px solid var(--pmw-chat-error, #b42318)}.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:4px;padding:12px;border-radius:12px;background:var(--pmw-chat-other-bg, #f1f3f6)}.pmw-chat__typing span{width:6px;height:6px;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__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:12px}.pmw-chat__ended p{margin:0 0 8px}.pmw-chat__composer{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;padding:10px 12px;border-top:1px solid var(--pmw-chat-border, #c9ced6)}.pmw-chat__composer textarea{flex:1;min-width:0;resize:none}.pmw-chat__composer textarea,.pmw-chat__field input,.pmw-chat__field textarea{font:inherit;font-size:16px;padding:8px 10px;border-radius:8px;border:1px solid var(--pmw-chat-field-border, #7d8590);background:#fff;color:var(--pmw-chat-field-fg, #1f2328)}.pmw-chat__send{min-height:44px;padding:8px 16px;border:0;border-radius:8px;font-weight:600;background:var(--pmw-chat-accent, #0a1b3d);color:var(--pmw-chat-accent-fg, #ffffff)}.pmw-chat__send:disabled,.pmw-chat__composer textarea:disabled{cursor:not-allowed;opacity:.6}.pmw-chat__restart{min-height:44px;padding:8px 16px;border-radius:8px;border:1px solid var(--pmw-chat-link, #0a1b3d);background:none;color:var(--pmw-chat-link, #0a1b3d);font-weight:600}.pmw-chat__composer .pmw-turnstile,.pmw-chat__fallback .pmw-turnstile{flex-basis:100%}.pmw-chat__attach{flex:none;position:relative;width:44px;height:44px;border-radius:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;border:1px solid var(--pmw-chat-field-border, #7d8590);color:var(--pmw-chat-field-fg, #1f2328);background:#fff}.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{flex-basis:100%;margin:0;font-size:14px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__composer .pmw-chat__photo-error{flex-basis:100%}.pmw-chat__photo{display:block;max-width:100%;max-height:220px;border-radius:8px}.pmw-chat__fallback{display:flex;flex-direction:column;gap:10px;margin-top:12px}.pmw-chat__fb-title{margin:0;font-weight:600}.pmw-chat__fb-intro{margin:0;font-size:15px}.pmw-chat__field{display:flex;flex-direction:column;gap:4px}.pmw-chat__field label{font-size:14px;font-weight:600}.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 Be=new Set;function $e(r,{doc:c=document,attrs:o={}}={}){if(Be.has(r)||(Be.add(r),c.querySelector(`script[src="${r}"]`)))return;let t=c.createElement("script");t.src=r,t.async=!0;for(let[d,h]of Object.entries(o))t.setAttribute(d,h);c.head.append(t)}function je({doc:r=document,hostname:c=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 d=String(c).toLowerCase();return t.some(h=>typeof h=="string"&&h.toLowerCase()===d)}var lt="https://challenges.cloudflare.com/turnstile/v0/api.js";var pt=15e3,Fe=200;function fe(r=document){$e(lt,{doc:r})}var dt=r=>r.querySelector('input[name="cf-turnstile-response"]')?.value??"",ut=r=>{let c=r.querySelector(".cf-turnstile");return!!c&&c.offsetHeight>0};async function Je(r,{win:c=window,timeoutMs:o=pt}={}){typeof r.pmwTurnstileLoad=="function"?r.pmwTurnstileLoad():fe(c.document);let t=0;for(;dt(r)==="";){if(!ut(r)){if(t>=o)return!1;t+=Fe}await new Promise(d=>c.setTimeout(d,Fe))}return!0}function Ve(r,{win:c=window}={}){let o=r.querySelector(".cf-turnstile");if(!o||o.offsetHeight>0)return!1;try{return c.turnstile?.reset(o),!0}catch{return!1}}var Ke={unauthorized:4401,expired:4410,rpc:4426,deleted:4404};var ht={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",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:"Le clavardage a \xE9t\xE9 mis \xE0 jour. Rechargez la page pour continuer.",unavailable:"Le clavardage n\u2019est pas disponible pour le moment.",turnstile:"La v\xE9rification anti-robot n\u2019a pas abouti. R\xE9essayez.",tooMany:"Trop de messages en peu de temps. R\xE9essayez dans une minute.",fallbackTitle:"Laissez-nous vos coordonn\xE9es",fallbackCap:"L\u2019assistant ne peut plus r\xE9pondre aujourd\u2019hui. Laissez vos coordonn\xE9es et on vous recontactera.",fallbackDown:"L\u2019assistant ne peut pas r\xE9pondre pour le moment. Laissez vos coordonn\xE9es et on vous recontactera.",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",connecting:"Connecting\u2026",reconnecting:"Connection lost, reconnecting\u2026",notSent:"Not sent.",retry:"Retry",ended:"This conversation has ended.",newConversation:"New conversation",reload:"The chat was updated. Reload the page to continue.",unavailable:"The chat is not available right now.",turnstile:"The anti-robot check did not go through. Try again.",tooMany:"Too many messages in a short time. Try again in a minute.",fallbackTitle:"Leave us your details",fallbackCap:"The assistant cannot answer any more today. Leave your details and we will get back to you.",fallbackDown:"The assistant cannot answer right now. 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."}},Q=r=>String(r??"").slice(0,2).toLowerCase()==="en"?"en":"fr";function me(r,c={}){let o=ht[Q(r)],t={...o};for(let[d,h]of Object.entries(c??{}))typeof h=="string"&&d in o&&(t[d]=h);return t}var W=(r,c)=>r.replace(/\{(\w+)\}/g,(o,t)=>t in c?String(c[t]):o);var we="pmw-chat",Ye=[1e3,2e3,4e3,8e3,16e3,3e4],Ge=3,ft="chat-lead",mt=3,wt=1600,gt=.85,_t=3e3,bt=8e3,xt='<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 8h3l2-3h6l2 3h3v11H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="2"/></svg>';async function vt(r,c){let o=await r.createImageBitmap(c,{imageOrientation:"from-image"}),t=Math.min(1,wt/Math.max(o.width,o.height)),d=Math.max(1,Math.round(o.width*t)),h=Math.max(1,Math.round(o.height*t)),f=r.document.createElement("canvas");return f.width=d,f.height=h,f.getContext("2d").drawImage(o,0,0,d,h),o.close?.(),new Promise((I,Z)=>f.toBlob(z=>z?I(z):Z(new Error("encode")),"image/jpeg",gt))}var yt={fr:{nos:"vos",notre:"votre",nous:"vous"},en:{our:"your",us:"you",we:"you"}},St={fr:/^une question sur (.+?)\s*\?$/i,en:/^(?:a |any )?questions? (?:about|on) (.+?)\s*\?$/i};function kt(r,c){let o=Q(c),t=me(o),d=String(r??"").trim().match(St[o]);if(!d)return t.demoQuestion;let h=d[1].replace(/[\p{L}’']+/gu,f=>yt[o][f.toLowerCase()]??f);return W(t.demoAsk,{topic:h})}var Tt='<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 s(r,c,o={},t){let d=r.createElement(c);for(let[h,f]of Object.entries(o))f!=null&&f!==!1&&d.setAttribute(h,f===!0?"":f);return t!==void 0&&(d.textContent=t),d}var Lt=r=>({load:()=>fe(r.document),async token(c){return await Je(c,{win:r}),c.querySelector('input[name="cf-turnstile-response"]')?.value??""},reset:c=>Ve(c,{win:r})});function Ut(r,c={}){if(r.pmwChat)return r.pmwChat;let o=c.win??window,t=o.document,d=c.WebSocket??o.WebSocket,h=c.fetch??((...e)=>o.fetch(...e)),f=c.turnstile??Lt(o),I=c.uuid??(()=>o.crypto.randomUUID()),Z=c.shrink??(e=>vt(o,e)),z=c.preview??(e=>new Promise((n,a)=>{let l=new o.FileReader;l.onload=()=>n(String(l.result)),l.onerror=()=>a(l.error),l.readAsDataURL(e)})),v={};try{v=JSON.parse(r.querySelector("[data-pmw-chat-config]")?.textContent??"{}")}catch{}let H=Q(v.lang),i=me(H,v.strings),ge=r.dataset.pmwChatOpener??"",Qe=r.querySelector(".pmw-chat__bubble"),m=c.demo??(v.demo===!0||!je({doc:t,hostname:o.location.hostname}));t.getElementById("pmw-chat-style")||t.head.append(s(t,"style",{id:"pmw-chat-style"},Ue));let b=s(t,"div",{class:"pmw-chat__panel",role:"dialog","aria-modal":"true","aria-labelledby":"pmw-chat-name",hidden:!0}),_e=s(t,"div",{class:"pmw-chat__head"}),ee=r.querySelector(".pmw-chat__avatar")?.cloneNode(!0)??s(t,"span",{class:"pmw-chat__avatar","aria-hidden":"true"});for(let e of ee.querySelectorAll("[id]")){let n=e.id;e.id=`${n}-head`;for(let a of ee.querySelectorAll(`[mask="url(#${n})"]`))a.setAttribute("mask",`url(#${n}-head)`)}let be=s(t,"div",{class:"pmw-chat__who"}),xe=s(t,"p",{class:"pmw-chat__name",id:"pmw-chat-name"}),D=s(t,"p",{class:"pmw-chat__role"}),ve=s(t,"span",{class:"pmw-chat__dot","aria-hidden":"true",hidden:!0}),U=s(t,"button",{type:"button",class:"pmw-chat__close","aria-label":i.close});U.innerHTML=Tt,be.append(xe,D),_e.append(ee,be,ve,U);let te=s(t,"p",{class:"pmw-chat__notice"},i.automated);v.privacyHref&&(te.append(" "),te.append(s(t,"a",{href:v.privacyHref},i.privacy)));let x=s(t,"ol",{class:"pmw-chat__log"}),We=s(t,"li",{class:"pmw-chat__msg pmw-chat__msg--bot pmw-chat__msg--greeting"},ge),C=s(t,"li",{class:"pmw-chat__typing","aria-hidden":"true",hidden:!0});C.append(s(t,"span"),s(t,"span"),s(t,"span")),x.append(We);let ne=s(t,"p",{class:"pmw-chat__state",role:"status"}),ye=s(t,"p",{class:"pmw-chat__sr","aria-live":"polite"}),Se=e=>s(t,"div",{class:"cf-turnstile pmw-turnstile","data-sitekey":v.siteKey??"","data-action":"chat","data-theme":"light","data-language":H,"data-appearance":"interaction-only","data-size":"flexible"}),w=s(t,"form",{class:"pmw-chat__composer",novalidate:!0}),y=s(t,"textarea",{id:"pmw-chat-input",name:"message",rows:"2",maxlength:String(2e3),autocomplete:"off",disabled:m,"aria-describedby":m?"pmw-chat-demo-hint":null}),Ze=s(t,"button",{type:"submit",class:"pmw-chat__send",disabled:m},i.send);w.append(s(t,"label",{class:"pmw-chat__sr",for:"pmw-chat-input"},i.messageLabel),y,Ze),m?w.append(s(t,"p",{class:"pmw-chat__hint",id:"pmw-chat-demo-hint"},i.demoHint)):w.append(Se(w));let P=null,E=s(t,"p",{class:"pmw-chat__error pmw-chat__photo-error",role:"alert"});if(v.photos===!0){let e=s(t,"label",{class:"pmw-chat__attach"});P=s(t,"input",{type:"file",class:"pmw-chat__sr",accept:"image/*",multiple:!0,"aria-describedby":"pmw-chat-photo-hint",disabled:m}),e.innerHTML=xt,e.prepend(P),e.append(s(t,"span",{class:"pmw-chat__sr"},i.attachPhoto)),w.insertBefore(e,y),w.append(s(t,"p",{class:"pmw-chat__hint",id:"pmw-chat-photo-hint"},i.photoHint),E)}let B=s(t,"div",{class:"pmw-chat__ended",hidden:!0}),ke=s(t,"p",{},i.ended),$=s(t,"button",{type:"button",class:"pmw-chat__restart"},i.newConversation);B.append(ke,$);let g=s(t,"form",{class:"pmw-chat__fallback",novalidate:!0,hidden:!0,"aria-labelledby":"pmw-chat-fb-title"}),Te=s(t,"p",{class:"pmw-chat__fb-intro"}),ae=(e,n,a)=>{let l=`pmw-chat-fb-${e}`,p=s(t,"div",{class:"pmw-chat__field"});return p.append(s(t,"label",{for:l},i[e]),s(t,n,{id:l,name:e,required:!0,...a})),p},j=s(t,"p",{class:"pmw-chat__error",role:"alert"});g.append(s(t,"p",{class:"pmw-chat__fb-title",id:"pmw-chat-fb-title"},i.fallbackTitle),Te,ae("name","input",{autocomplete:"name",maxlength:"100"}),ae("contact","input",{autocomplete:"on",maxlength:"200"}),ae("need","textarea",{rows:"3",maxlength:"2000"}),j,s(t,"button",{type:"submit",class:"pmw-chat__send"},i.fallbackSend)),m||g.append(Se(g));let Le=s(t,"div",{class:"pmw-chat__body"});Le.append(te,x,ne,B,g),b.append(_e,Le,w,ye),r.append(b);let u=null;if(!m)try{u=JSON.parse(o.sessionStorage.getItem(we)??"null")}catch{u=null}let Ce=()=>{try{o.sessionStorage.setItem(we,JSON.stringify(u))}catch{}},_=null,oe=0,re=null,R=null,F=0,J="bot",V=null,A=!1,Ee=0,se=null,ie=new Set,k=new Map,K=new Set,ce=0,Ae=()=>new URLSearchParams(o.location.hash.slice(1)).get("pmw-chat-botName")?.trim()||null,Oe=m?Ae():null,le=()=>m?Oe:u?.botName||null;function q(){let e=J==="human";xe.textContent=e?V||i.assistant:le()||i.assistant,D.textContent=e?i.live:le()?i.assistant:"",D.hidden=D.textContent==="",ve.hidden=!e,b.dataset.mode=J}q();let T=e=>{ne.textContent=e??"",ne.hidden=!e};T("");let Ne=e=>e.role==="visitor"?i.you:e.role==="owner"?e.by||V||i.assistant:le()||i.assistant;function O(e,n){let a=s(t,"li",{class:`pmw-chat__msg pmw-chat__msg--${n}`,"data-id":e}),l=s(t,"span",{class:"pmw-chat__sr"}),p=s(t,"span",{class:"pmw-chat__text"});return a.append(l,p),x.insertBefore(a,C.parentNode===x?C:null),a}let Me=e=>[...x.querySelectorAll("li[data-id]")].find(n=>n.dataset.id===e),L=(e,n,a)=>{e.firstChild.textContent=W(i.said,{name:Ne(n),text:""}),e.lastChild.textContent=a===""&&n.photos?.length&&!e.querySelector("img")?i.photoSent:a};function pe(){C.hidden=!0,o.clearTimeout(se)}function Pe(e,{announce:n}){if(ie.has(e.seq))return;ie.add(e.seq),F=Math.max(F,e.seq);let a=Me(e.id);a||(a=O(e.id,e.role)),a.classList.remove("is-streaming","is-pending","is-failed"),a.querySelector(".pmw-chat__retry")?.remove(),L(a,e,e.text),e.role==="visitor"?(k.delete(e.id),e.photos?.length&&K.add(e.id)):(pe(),n&&(ye.textContent=W(i.said,{name:Ne(e),text:e.text})),b.hidden&&r.setAttribute("data-unread","")),N()}function et(e,n){pe();let a=Me(e);a||(a=O(e,"bot"),a.classList.add("is-streaming"),L(a,{role:"bot"},"")),a.lastChild.textContent+=n,N()}let N=()=>{x.scrollTop=x.scrollHeight};if(m){L(O("demo-question","visitor"),{role:"visitor"},kt(ge,H));let e=O("demo-answer","bot");L(e,{role:"bot"},i.demoAnswer),o.addEventListener("hashchange",()=>{Oe=Ae(),q(),L(e,{role:"bot"},i.demoAnswer)})}function X(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=s(t,"span",{class:"pmw-chat__retry"},`${i.notSent} `),a=s(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"),u?Y()?de(e):M():ue()}),n.append(a),e.li.append(n)}let Y=()=>!!_&&_.readyState===1;function de(e){if(!(e.failed||!Y())){if(e.tries>=Ge)return X(e);e.tries++,_.send(JSON.stringify({type:"message",id:e.id,text:e.text}))}}function M(){if(!u||A||_)return;o.clearTimeout(re);let e=o.location.protocol==="https:"?"wss:":"ws:",n=new URLSearchParams({c:u.c,k:u.k}),a=new d(`${e}//${o.location.host}/api/chat/ws?${n}`);_=a,a.onopen=()=>{oe=0,T(""),a.send(JSON.stringify({type:"hello",since:F}));for(let l of k.values())de(l)},a.onmessage=l=>{let p;try{p=JSON.parse(l.data)}catch{return}nt(p)},a.onclose=l=>{_===a&&(_=null);for(let S of k.values())S.tries>=Ge&&X(S);if(l.code>=4e3&&l.code<5e3)return tt(l.code);if(A)return;T(i.reconnecting);let p=Ye[Math.min(oe,Ye.length-1)];oe++,re=o.setTimeout(M,p)}}function tt(e){if(e===Ke.rpc)return G(i.reload,!1);G(i.ended,!0)}function G(e,n){A=!0,o.clearTimeout(re),T(""),ke.textContent=e,$.hidden=!n,B.hidden=!1,w.hidden=!0;for(let a of k.values())X(a)}function Re(e){J=e.mode==="human"?"human":"bot",V=typeof e.ownerName=="string"?e.ownerName:null,q(),e.closed&&G(i.ended,!0)}function nt(e){switch(e?.type){case"history":for(let n of e.messages??[])Pe(n,{announce:!1});return Re(e);case"message":return Pe(e.msg,{announce:e.msg?.role!=="visitor"});case"delta":return et(e.id,e.text);case"typing":return e.role==="visitor"?void 0:(C.hidden=!1,x.append(C),o.clearTimeout(se),se=o.setTimeout(pe,bt),N());case"status":return Re(e);case"fallback":return He(e.reason==="cap"?i.fallbackCap:i.fallbackDown);case"lead":return Ie();case"error":(e.code==="closed"||e.code==="deleted")&&G(i.ended,!0)}}let qe=!1;function Ie(){if(!(qe||u?.lead)){qe=!0,u&&(u.lead=!0,Ce());try{o.pmw?.event(ft)}catch{}}}async function ze(e,n){let a="";try{a=await f.token(e)}catch{a=""}let l,p=null;try{l=await h("/api/chat/start",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({token:a,lang:H,page:o.location.pathname,...n})}),p=await l.json()}catch{l=null}try{f.reset(e)}catch{}return l?.ok&&p?{ok:!0,data:p}:{ok:!1,error:l?.status===403?i.turnstile:l?.status===429?i.tooMany:null,cap:l?.status===409&&p?.error==="cap"}}function ue(){return R||u||(T(i.connecting),R=ze(w,{}).then(e=>{if(R=null,e.ok&&e.data.conversationId&&e.data.visitorKey){u={c:e.data.conversationId,k:e.data.visitorKey,botName:typeof e.data.botName=="string"?e.data.botName:null},Ce(),q(),M();return}for(let n of k.values())X(n);if(e.error)return T(e.error);T(""),He(e.cap?i.fallbackCap:i.unavailable)})),R}function He(e){Te.textContent=e,g.hidden=!1,w.hidden=!0,g.querySelector("input")?.focus()}g.addEventListener("submit",async e=>{e.preventDefault();let n=p=>g.elements[p].value.trim(),a={name:n("name"),contact:n("contact"),need:n("need")};if(!a.name||!a.contact||!a.need){j.textContent=i.fallbackMissing;return}j.textContent="";let l=await ze(g,{fallbackLead:a});if(!l.ok){j.textContent=l.error??i.unavailable;return}g.replaceChildren(s(t,"p",{class:"pmw-chat__fb-done",role:"status"},i.fallbackSent)),Ie()});let at={413:"photoTooLarge",415:"photoUnreadable",409:"photoLimit",429:"tooMany"};async function ot(e){let n,a;try{n=await Z(e),a=await z(n)}catch{E.textContent=i.photoUnreadable;return}let l=I(),p=O(l,"visitor");p.classList.add("is-pending"),p.insertBefore(s(t,"img",{class:"pmw-chat__photo",src:a,alt:i.photoAlt}),p.lastChild),L(p,{role:"visitor"},""),N(),u||await ue();let S=null;if(u){let it=new URLSearchParams({c:u.c,k:u.k,id:l});try{S=await h(`/api/chat/photo?${it}`,{method:"POST",headers:{"content-type":"image/jpeg"},body:n})}catch{S=null}}if(S?.ok){p.classList.remove("is-pending"),K.add(l),_||M();return}p.remove(),E.textContent=i[at[S?.status]]??i.photoFailed}P?.addEventListener("change",async()=>{let e=[...P.files??[]];if(P.value="",E.textContent="",A||m||e.length===0)return;let n=mt-K.size-ce;e.length>n&&(E.textContent=i.photoLimit);let a=e.slice(0,Math.max(0,n));ce+=a.length;for(let l of a)try{await ot(l)}finally{ce--}});function De(){let e=y.value.trim();if(!e||A||m)return;y.value="";let n={id:I(),text:e.slice(0,2e3),tries:0,failed:!1};n.li=O(n.id,"visitor"),n.li.classList.add("is-pending"),L(n.li,{role:"visitor"},n.text),k.set(n.id,n),N(),u?Y()?de(n):M():ue()}w.addEventListener("submit",e=>{e.preventDefault(),De()}),y.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&!e.isComposing&&(e.preventDefault(),De())}),y.addEventListener("input",()=>{let e=Date.now();Y()&&e-Ee>_t&&(Ee=e,_.send(JSON.stringify({type:"typing"})))}),$.addEventListener("click",()=>{u=null;try{o.sessionStorage.removeItem(we)}catch{}try{_?.close(1e3)}catch{}_=null,A=!1,F=0,ie.clear(),k.clear(),K.clear(),E.textContent="",J="bot",V=null,q();for(let e of[...x.querySelectorAll("li[data-id]")])e.remove();B.hidden=!0,w.hidden=!1,y.focus()});let rt=()=>[...b.querySelectorAll('a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])')].filter(e=>!e.disabled&&!e.closest("[hidden]"));b.addEventListener("keydown",e=>{if(e.key==="Escape"){e.preventDefault(),he();return}if(e.key!=="Tab")return;let n=rt();if(n.length===0)return;let a=n[0],l=n[n.length-1];e.shiftKey&&t.activeElement===a?(e.preventDefault(),l.focus()):!e.shiftKey&&t.activeElement===l&&(e.preventDefault(),a.focus())}),U.addEventListener("click",()=>he());function st(){m||f.load(),b.hidden=!1,r.setAttribute("data-open",""),r.removeAttribute("data-unread"),u&&M(),(m?U:w.hidden?g.hidden?$:g.querySelector("input"):y)?.focus(),N()}function he(){b.hidden=!0,r.removeAttribute("data-open"),Qe?.focus()}return r.pmwChat={open:st,close:he,panel:b},r.pmwChat}export{Ye as BACKOFF_MS,ft as GOAL,mt as MAX_PHOTOS,Ge as MAX_TRIES,wt as PHOTO_EDGE,gt as PHOTO_QUALITY,we as SESSION_KEY,kt as demoQuestion,Ut as mountChat};
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};
package/manifest.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "category": "visitors",
7
7
  "icon": "message-circle",
8
8
  "load": "interaction",
9
- "inlineBudget": 2048,
9
+ "inlineBudget": 2304,
10
10
  "preview": "test",
11
11
  "activeWhen": ["enabled"],
12
12
  "config": {
@@ -19,8 +19,7 @@
19
19
  },
20
20
  "bindings": [
21
21
  { "kind": "service", "binding": "CHAT", "service": "pmeweb-chat", "entrypoint": "ChatApi" },
22
- { "kind": "ratelimit", "binding": "CHAT_LIMIT" },
23
- { "kind": "ratelimit", "binding": "CHAT_START_LIMIT" }
22
+ { "kind": "ratelimit", "binding": "CHAT_LIMIT" }
24
23
  ],
25
24
  "secrets": ["TURNSTILE_SECRET"],
26
25
  "goals": ["chat-lead"],
@@ -58,10 +57,7 @@
58
57
  "help": { "fr": "Texto court à chaque demande, si les textos sont activés.", "en": "A short text for each request, when texts are on." } },
59
58
  { "key": "consignes", "kind": "longtext",
60
59
  "label": { "fr": "Consignes", "en": "Instructions" },
61
- "help": { "fr": "Heures, territoire, sujets à éviter; le contenu du site y est déjà.", "en": "Hours, service area, topics to avoid; the site’s content is already known." } },
62
- { "key": "dailyCap", "kind": "money", "required": true,
63
- "label": { "fr": "Plafond quotidien", "en": "Daily cap" },
64
- "help": { "fr": "Au-delà, le site affiche le formulaire de contact jusqu’à minuit.", "en": "Past it, the site shows the contact form until midnight." } }
60
+ "help": { "fr": "Heures, territoire, sujets à éviter; le contenu du site y est déjà.", "en": "Hours, service area, topics to avoid; the site’s content is already known." } }
65
61
  ],
66
62
  "config": [
67
63
  { "key": "enabled", "kind": "boolean", "editor": "client",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmeweb/plugin-chat",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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/Chat.astro CHANGED
@@ -46,6 +46,9 @@ const show = enabled || process.env.PMEWEB_PORTAL_PREVIEW === '1';
46
46
  const lang = langOf(rawLang);
47
47
  const s = stringsFor(lang, strings);
48
48
  if (typeof opener !== 'string' || opener.trim() === '') throw new Error('chat : pas d’accroche pour cette page');
49
+ // At most OPENER_MAX characters (operator, 2026-11-13): two lines on a phone, never more.
50
+ const OPENER_MAX = 50;
51
+ if ([...opener.trim()].length > OPENER_MAX) throw new Error(`chat : accroche de ${[...opener.trim()].length} caractères, ${OPENER_MAX} au plus : « ${opener.trim()} »`);
49
52
  if (typeof siteKey !== 'string' || siteKey === '') throw new Error('chat : la clé de site Turnstile manque');
50
53
 
51
54
  const avatar = String(config.avatar ?? '');
package/src/chat.css CHANGED
@@ -12,6 +12,19 @@
12
12
  display: flex; flex-direction: column; align-items: flex-end; gap: 10px;
13
13
  font-family: var(--pmw-chat-font, inherit); font-size: 16px; line-height: 1.4;
14
14
  color: var(--pmw-chat-fg, #1f2328);
15
+ /* `bottom` eases when a site lifts the launcher above its sticky bar (--pmw-chat-bottom). */
16
+ transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s, bottom 0.3s ease;
17
+ /* The root's own box (the gap, a faded opener's kept space) never catches a tap meant for the page. */
18
+ pointer-events: none;
19
+ }
20
+ .pmw-chat > * { pointer-events: auto; }
21
+ /* Out of the footer's way: the trigger makes the root `inert` while the footer (or
22
+ [data-pmw-chat-avoid]) is in view, never while the panel is open or focus is inside. It fades
23
+ and slides 8 px down, then turns `visibility: hidden` once the fade is over; it comes back at
24
+ once. Opacity and transform only: nothing moves on the page. */
25
+ .pmw-chat[inert]:not([data-open]):not(:focus-within) {
26
+ opacity: 0; transform: translateY(8px); visibility: hidden;
27
+ transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s 0.2s, bottom 0.3s ease;
15
28
  }
16
29
  .pmw-chat button { font: inherit; cursor: pointer; }
17
30
  .pmw-chat button:focus-visible {
@@ -40,7 +53,8 @@
40
53
  .pmw-chat__avatar svg { display: block; width: 100%; height: 100%; }
41
54
  .pmw-chat__label { font-size: 15px; font-weight: 600; white-space: nowrap; }
42
55
 
43
- /* The opener: once per visit, after 20 s, at 30 % of the page, or on exit intent (trigger.js). */
56
+ /* The opener: once per visit, after 20 s, at 30 % of the page, or on exit intent (trigger.js), never
57
+ while the footer is in view. */
44
58
  .pmw-chat__teaser[hidden] { display: none; }
45
59
  .pmw-chat__teaser {
46
60
  position: relative; max-width: min(300px, calc(100vw - 32px));
@@ -57,9 +71,30 @@
57
71
  }
58
72
  .pmw-chat__teaser-close svg { width: 16px; height: 16px; }
59
73
  @keyframes pmw-chat-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
74
+ @keyframes pmw-chat-out { to { opacity: 0; visibility: hidden; } }
75
+
76
+ /* A phone (under 640 px, where the panel goes full screen): the bubble is a 56 px round button,
77
+ the face alone. « Assistant virtuel » stays in the DOM, visually hidden; the button's
78
+ aria-label still names it. The opener fades out by itself 8 s after it appears (its space is
79
+ kept, above the bubble, so nothing moves). */
80
+ @media (max-width: 639.98px) {
81
+ .pmw-chat__bubble {
82
+ position: relative; box-sizing: border-box; width: 56px; height: 56px;
83
+ padding: 0; justify-content: center;
84
+ }
85
+ .pmw-chat__label {
86
+ position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
87
+ clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
88
+ }
89
+ .pmw-chat__teaser { animation: pmw-chat-in 0.22s ease both, pmw-chat-out 0.2s ease 8s forwards; }
90
+ }
60
91
  @media (prefers-reduced-motion: reduce) {
92
+ .pmw-chat, .pmw-chat[inert]:not([data-open]):not(:focus-within) { transition: none; }
61
93
  .pmw-chat__teaser { animation: none; }
62
94
  }
95
+ @media (prefers-reduced-motion: reduce) and (max-width: 639.98px) {
96
+ .pmw-chat__teaser { animation: pmw-chat-out 0s 8s forwards; }
97
+ }
63
98
  /* While the panel is open the bubble and the opener step aside. */
64
99
  .pmw-chat[data-open] .pmw-chat__bubble, .pmw-chat[data-open] .pmw-chat__teaser { display: none; }
65
100
  @media print { .pmw-chat { display: none; } }
package/src/colors.js CHANGED
@@ -59,41 +59,65 @@ export function avatarTheme(avatarBg, colors = {}) {
59
59
  }
60
60
 
61
61
  const ERRORS = ['#b42318', '#ffb4ab']
62
+ /** The « Vous répond en direct » line and the takeover dot: a dark green on light, a light one on dark. */
63
+ const LIVES = ['#1a7f37', '#3fb950']
64
+ const DEFAULT_FG = '#1f2328'
62
65
 
63
66
  /** `fg` laid over `bg` at `alpha`, as an opaque #rrggbb. */
64
- function mix(fg, bg, alpha) {
67
+ export function mix(fg, bg, alpha) {
65
68
  const ch = (h, i) => parseInt(h.slice(i, i + 2), 16)
66
69
  return `#${[1, 3, 5].map((i) => Math.round(ch(fg, i) * alpha + ch(bg, i) * (1 - alpha)).toString(16).padStart(2, '0')).join('')}`
67
70
  }
68
71
 
69
72
  /**
70
- * The panel (and the bubble beside it) in the site's colours, two groups each all-or-nothing:
71
- * - primary: the header, the visitor's bubbles and the send button, text white or ink at 4.5:1;
72
- * - surface: the panel and the bubble in `surface`, text in `ink` at 4.5:1, the bot's bubbles in
73
- * `background`, and the colours that sit on them (muted, link, focus, error) re-derived so each
74
- * keeps its minimum. A group whose pair fails sets nothing: the package defaults stay.
73
+ * The panel (and the bubble beside it) in the site's colours, three groups each all-or-nothing:
74
+ * - primary: the visitor's bubbles and the send button, text white or ink at 4.5:1;
75
+ * - surface: the panel, its header and the bot's bubbles in `surface`, text in `ink` at 4.5:1; the
76
+ * thread behind the bubbles a light tint of the accent (else primary, else ink) over surface (the
77
+ * surface itself when the tint would cost a pair); the lines a stronger tint (decoration); and the colours that sit on the
78
+ * surface AND the thread (muted, link, live, error, focus) re-derived so each keeps its minimum;
79
+ * - owner: the bubbles of a person who took over, a tint of the accent (else primary) over the
80
+ * panel, text at 4.5:1; failing that, with a themed surface, the panel's own colour.
81
+ * A group whose pair fails sets nothing: the package defaults stay.
75
82
  */
76
83
  export function panelTheme(colors = {}) {
77
84
  const v = {}
78
- const { primary, ink, surface, background } = colors
85
+ const { primary, accent, ink, surface, background } = colors
79
86
  if (primary) {
80
87
  const fg = textOn(primary, ink, 4.5)
81
88
  if (fg) Object.assign(v, { '--pmw-chat-accent': primary, '--pmw-chat-accent-fg': fg })
82
89
  }
83
- if (surface && ink && contrast(ink, surface) >= 4.5) {
90
+ const themed = !!(surface && ink && contrast(ink, surface) >= 4.5)
91
+ const hue = accent ?? primary
92
+ if (themed) {
93
+ const tint = mix(hue ?? ink, surface, 0.04)
94
+ const thread = contrast(ink, tint) >= 4.5 ? tint : surface
95
+ const both = (c, min = 4.5) => contrast(c, surface) >= min && contrast(c, thread) >= min
84
96
  const muted = mix(ink, surface, 0.75)
85
97
  const page = background ?? WHITE
86
98
  Object.assign(v, {
87
99
  '--pmw-chat-panel-bg': surface,
88
100
  '--pmw-chat-bubble-bg': surface,
89
101
  '--pmw-chat-fg': ink,
90
- '--pmw-chat-other-bg': background && contrast(ink, background) >= 4.5 ? background : surface,
91
- '--pmw-chat-muted': contrast(muted, surface) >= 4.5 ? muted : ink,
92
- '--pmw-chat-link': primary && contrast(primary, surface) >= 4.5 ? primary : ink,
93
- '--pmw-chat-focus': contrast(ink, page) >= 3 ? ink : INK_FALLBACK,
94
- '--pmw-chat-error': ERRORS.find((e) => contrast(e, surface) >= 4.5) ?? ink,
102
+ '--pmw-chat-other-bg': surface,
103
+ '--pmw-chat-thread-bg': thread,
104
+ '--pmw-chat-line': mix(hue ?? ink, surface, 0.1),
105
+ '--pmw-chat-rule': mix(hue ?? ink, surface, 0.2),
106
+ '--pmw-chat-muted': both(muted) ? muted : ink,
107
+ '--pmw-chat-link': primary && both(primary) ? primary : ink,
108
+ '--pmw-chat-live': LIVES.find((c) => both(c)) ?? ink,
109
+ '--pmw-chat-focus': contrast(ink, page) >= 3 && both(ink, 3) ? ink : INK_FALLBACK,
110
+ '--pmw-chat-error': ERRORS.find((e) => both(e)) ?? ink,
95
111
  })
96
112
  }
113
+ const base = themed ? surface : WHITE
114
+ const text = themed ? ink : DEFAULT_FG
115
+ const owner = hue ? mix(hue, base, 0.08) : null
116
+ if (owner && contrast(text, owner) >= 4.5) {
117
+ Object.assign(v, { '--pmw-chat-owner-bg': owner, '--pmw-chat-owner-border': mix(hue, base, 0.22) })
118
+ } else if (themed) {
119
+ Object.assign(v, { '--pmw-chat-owner-bg': surface, '--pmw-chat-owner-border': v['--pmw-chat-rule'] })
120
+ }
97
121
  return v
98
122
  }
99
123
 
package/src/strings.js CHANGED
@@ -21,19 +21,21 @@ export const STRINGS = {
21
21
  messageLabel: 'Votre message',
22
22
  send: 'Envoyer',
23
23
  live: 'Vous répond en direct',
24
+ relay: '{name} a pris le relais',
25
+ relayGeneric: 'Une personne de l’équipe a pris le relais',
24
26
  connecting: 'Connexion…',
25
27
  reconnecting: 'Connexion perdue, reconnexion…',
26
28
  notSent: 'Non envoyé.',
27
29
  retry: 'Réessayer',
28
30
  ended: 'Cette conversation est terminée.',
29
31
  newConversation: 'Nouvelle conversation',
30
- reload: 'Le clavardage a été mis à jour. Rechargez la page pour continuer.',
31
- unavailable: 'Le clavardage n’est pas disponible pour le moment.',
32
- turnstile: 'La vérification anti-robot n’a pas abouti. Réessayez.',
33
- tooMany: 'Trop de messages en peu de temps. Réessayez dans une minute.',
32
+ reload: 'Rechargez la page pour continuer.',
33
+ unavailable: 'Un problème est survenu. Réessayez dans un moment.',
34
+ turnstile: 'L’envoi n’a pas abouti. Réessayez.',
35
+ tooMany: 'Un instant, puis réessayez.',
34
36
  fallbackTitle: 'Laissez-nous vos coordonnées',
35
- fallbackCap: 'L’assistant ne peut plus répondre aujourd’hui. Laissez vos coordonnées et on vous recontactera.',
36
- fallbackDown: 'L’assistant ne peut pas répondre pour le moment. Laissez vos coordonnées et on vous recontactera.',
37
+ fallbackCap: 'Laissez vos coordonnées et nous vous recontacterons.',
38
+ fallbackDown: 'Laissez vos coordonnées et nous vous recontacterons.',
37
39
  name: 'Votre nom',
38
40
  contact: 'Téléphone ou courriel',
39
41
  need: 'Votre demande',
@@ -66,19 +68,21 @@ export const STRINGS = {
66
68
  messageLabel: 'Your message',
67
69
  send: 'Send',
68
70
  live: 'Replying live',
71
+ relay: '{name} took over',
72
+ relayGeneric: 'A team member took over',
69
73
  connecting: 'Connecting…',
70
74
  reconnecting: 'Connection lost, reconnecting…',
71
75
  notSent: 'Not sent.',
72
76
  retry: 'Retry',
73
77
  ended: 'This conversation has ended.',
74
78
  newConversation: 'New conversation',
75
- reload: 'The chat was updated. Reload the page to continue.',
76
- unavailable: 'The chat is not available right now.',
77
- turnstile: 'The anti-robot check did not go through. Try again.',
78
- tooMany: 'Too many messages in a short time. Try again in a minute.',
79
+ reload: 'Reload the page to continue.',
80
+ unavailable: 'Something went wrong. Try again in a moment.',
81
+ turnstile: 'Sending did not go through. Try again.',
82
+ tooMany: 'One moment, then try again.',
79
83
  fallbackTitle: 'Leave us your details',
80
- fallbackCap: 'The assistant cannot answer any more today. Leave your details and we will get back to you.',
81
- fallbackDown: 'The assistant cannot answer right now. Leave your details and we will get back to you.',
84
+ fallbackCap: 'Leave your details and we will get back to you.',
85
+ fallbackDown: 'Leave your details and we will get back to you.',
82
86
  name: 'Your name',
83
87
  contact: 'Phone or email',
84
88
  need: 'Your request',
package/src/trigger.js CHANGED
@@ -12,7 +12,15 @@
12
12
  //
13
13
  // The opener shows once per visit (sessionStorage), on the first of: 20 s on the page, 30 % of the
14
14
  // page scrolled, or exit intent (a fine pointer leaving through the top of the window). Never while
15
- // the panel is open, never again once dismissed or once the panel has been opened.
15
+ // the panel is open, never again once dismissed or once the panel has been opened, and never while
16
+ // the footer is in view (it waits for the next trigger then). On a phone it fades out after 8 s:
17
+ // that is chat.css (an animation), not code, to keep this file under its budget.
18
+ //
19
+ // OUT OF THE FOOTER'S WAY, on the real hosts only (off them the bubble is the preview's subject).
20
+ // While the first `[data-pmw-chat-avoid]`, else the page's last `footer`, is in view, the root is
21
+ // `inert` (out of the tab order and the accessibility tree): chat.css fades it out, then turns it
22
+ // `visibility: hidden`. Never while the panel is open or while focus is inside the root: a focusout
23
+ // re-checks, so closing the panel near the footer hides the bubble once focus moves on.
16
24
  import { isCanonical, onInteraction as interact } from '@pmeweb/loader'
17
25
 
18
26
  export const OPENER_KEY = 'pmw-chat-opener'
@@ -40,20 +48,29 @@ export function startChat({
40
48
  win.addEventListener('hashchange', view)
41
49
  }
42
50
 
51
+ let away = false
52
+ const sync = (f = doc.activeElement) => {
53
+ const h = away && !root.hasAttribute('data-open') && !root.contains(f)
54
+ root.toggleAttribute('inert', h)
55
+ }
56
+ const zone = doc.querySelector('[data-pmw-chat-avoid]') ?? [...doc.querySelectorAll('footer')].pop()
57
+ if (canonical && zone) {
58
+ new win.IntersectionObserver((l) => { away = l.at(-1).isIntersecting; sync() }).observe(zone)
59
+ root.addEventListener('focusout', (e) => sync(e.relatedTarget))
60
+ }
61
+
43
62
  const store = win.sessionStorage
44
63
  let done = false
45
64
  try { done = !!store?.getItem(OPENER_KEY) } catch { /* storage blocked: show once per page */ }
46
- const listeners = []
47
- const on = (t, ev, f) => { t.addEventListener(ev, f, { passive: true }); listeners.push([t, ev, f]) }
48
- let timer
65
+ // The scroll and exit listeners stay after the opener is done: they return at once (no layout read).
66
+ const on = (t, ev, f) => t.addEventListener(ev, (e) => done || f(e), { passive: true })
67
+ // A timer that fires once the opener is done finds `done` and returns.
49
68
  const finish = () => {
50
69
  done = true
51
70
  try { store?.setItem(OPENER_KEY, '1') } catch { /* nothing to remember with */ }
52
- win.clearTimeout(timer)
53
- for (const [t, ev, f] of listeners) t.removeEventListener(ev, f)
54
71
  }
55
72
  const show = () => {
56
- if (done || !teaser || root.hasAttribute('data-open')) return
73
+ if (done || away || !teaser || root.hasAttribute('data-open')) return
57
74
  finish()
58
75
  teaser.hidden = false
59
76
  }
@@ -65,7 +82,7 @@ export function startChat({
65
82
  if (t.hasAttribute('data-pmw-chat-open')) load().then((w) => w.open(), () => {})
66
83
  })
67
84
  if (!done && teaser && canonical) {
68
- timer = win.setTimeout(show, OPENER_DELAY_MS)
85
+ win.setTimeout(show, OPENER_DELAY_MS)
69
86
  on(win, 'scroll', () => {
70
87
  const room = doc.documentElement.scrollHeight - win.innerHeight
71
88
  if (room > 0 && win.scrollY / room >= OPENER_SCROLL) show()
package/src/widget.css CHANGED
@@ -1,10 +1,13 @@
1
1
  /* @pmeweb/plugin-chat: the panel, injected by the lazy widget. Like chat.css, the --pmw-chat-*
2
2
  variables are not declared here: each use carries its AA default as a fallback
3
- (a11y-tools/contrast.mjs): #1f2328 on #f1f3f6 14.21:1, #ffffff on #0a1b3d 16.98:1, #4b5563 on
4
- #ffffff 7.56:1, #b42318 on #ffffff 6.57:1, #0a1b3d on #ffffff 16.98:1. Field borders #7d8590 on
5
- #ffffff 3.73:1 (form borders need 3:1). The fields stay white with their own text colour
6
- (--pmw-chat-field-fg), so a dark site's light ink never lands on them. Chat.astro sets the panel's
7
- colours from the site's design.colors, each pair checked (src/colors.js panelTheme). */
3
+ (a11y-tools/contrast.mjs): #1f2328 on #ffffff 15.8:1 and on the thread #f7f8fb 14.88:1, on the
4
+ owner's #eef2fa 14.08:1; #ffffff on #0a1b3d 16.98:1; #4b5563 on #ffffff 7.56:1 and on #f7f8fb
5
+ 7.12:1; #1a7f37 (live) on #ffffff 5.08:1 and on #f7f8fb 4.78:1; #b42318 on #ffffff 6.57:1 and on
6
+ #f7f8fb 6.19:1; #0a1b3d on #ffffff 16.98:1. The composer's field border #7d8590 on #ffffff 3.73:1
7
+ (form borders need 3:1). The fields stay white with their own text colour (--pmw-chat-field-fg),
8
+ so a dark site's light ink never lands on them. Chat.astro sets the panel's colours from the
9
+ site's design.colors, each pair checked (src/colors.js panelTheme). The lines (--pmw-chat-line,
10
+ --pmw-chat-rule) are decoration: no text is read off them. */
8
11
  .pmw-chat__panel {
9
12
  position: fixed; z-index: 81; inset: 0;
10
13
  display: flex; flex-direction: column;
@@ -17,86 +20,156 @@
17
20
  inset: auto;
18
21
  right: calc(env(safe-area-inset-right, 0px) + var(--pmw-chat-right, 16px));
19
22
  bottom: calc(env(safe-area-inset-bottom, 0px) + var(--pmw-chat-bottom, 16px));
20
- width: 380px; height: min(600px, calc(100dvh - 32px));
21
- border: 1px solid var(--pmw-chat-border, #c9ced6); border-radius: 12px; overflow: hidden;
22
- box-shadow: 0 12px 36px rgb(0 0 0 / 0.22);
23
+ width: 380px; height: min(600px, calc(100dvh - 16px - env(safe-area-inset-bottom, 0px) - var(--pmw-chat-bottom, 16px)));
24
+ border: 1px solid var(--pmw-chat-line, #e6e9f0); border-radius: 20px; overflow: hidden;
25
+ box-shadow: 0 18px 48px rgb(16 24 40 / 0.18);
23
26
  }
24
27
  }
25
- .pmw-chat__panel button { font: inherit; cursor: pointer; }
28
+ /* :where() keeps this reset at one class, so .pmw-chat__send's weight (and every later button rule) wins. */
29
+ .pmw-chat__panel :where(button) { font: inherit; cursor: pointer; }
26
30
  .pmw-chat__panel :focus-visible { outline: 3px solid var(--pmw-chat-focus, #0a1b3d); outline-offset: 2px; }
27
- .pmw-chat__head :focus-visible { outline-color: var(--pmw-chat-accent-fg, #ffffff); }
28
31
 
32
+ /* The header: the panel's own colour, the face in its circle, the name, then one line under it
33
+ (the automated notice and the privacy link, or « Vous répond en direct » in green). */
29
34
  .pmw-chat__head {
30
- display: flex; align-items: center; gap: 10px; padding: 10px 8px 10px 12px;
31
- background: var(--pmw-chat-accent, #0a1b3d); color: var(--pmw-chat-accent-fg, #ffffff);
35
+ display: flex; align-items: center; gap: 12px; padding: 14px 10px 14px 16px;
36
+ border-bottom: 1px solid var(--pmw-chat-line, #e6e9f0);
37
+ }
38
+ @media (max-width: 639.98px) {
39
+ .pmw-chat__head { padding: 12px 8px 12px 16px; }
32
40
  }
33
- .pmw-chat__head .pmw-chat__avatar { width: 40px; height: 40px; box-shadow: none; background: var(--pmw-chat-avatar-bg, var(--pmw-chat-accent-fg, #ffffff)); color: var(--pmw-chat-avatar-fg, var(--pmw-chat-accent, #0a1b3d)); }
34
- .pmw-chat__head .pmw-chat__avatar img { width: 40px; height: 40px; }
35
- .pmw-chat__who { flex: 1; min-width: 0; }
41
+ .pmw-chat__face { position: relative; flex: none; display: flex; }
42
+ .pmw-chat__head .pmw-chat__avatar { width: 44px; height: 44px; box-shadow: none; }
43
+ .pmw-chat__head .pmw-chat__avatar--icon { box-shadow: 0 0 0 1.5px var(--pmw-chat-avatar-ring, transparent); }
44
+ .pmw-chat__head .pmw-chat__avatar img { width: 44px; height: 44px; }
45
+ .pmw-chat__head .pmw-chat__avatar--logo img { width: 32px; height: 32px; }
46
+ .pmw-chat__head .pmw-chat__avatar svg { width: 100%; height: 100%; }
47
+ .pmw-chat__dot {
48
+ position: absolute; right: -1px; bottom: -1px; width: 12px; height: 12px; border-radius: 50%;
49
+ background: var(--pmw-chat-live, #1a7f37); box-shadow: 0 0 0 2px var(--pmw-chat-panel-bg, #ffffff);
50
+ }
51
+ .pmw-chat__who { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
36
52
  .pmw-chat__name { margin: 0; font-weight: 600; font-size: 16px; }
37
- .pmw-chat__role { margin: 0; font-size: 14px; }
38
- .pmw-chat__dot { flex: none; width: 10px; height: 10px; border-radius: 50%; background: var(--pmw-chat-live, #3fb950); box-shadow: 0 0 0 2px var(--pmw-chat-accent, #0a1b3d); }
53
+ .pmw-chat__role, .pmw-chat__notice { margin: 0; font-size: 13px; line-height: 1.4; color: var(--pmw-chat-muted, #4b5563); }
54
+ .pmw-chat__panel[data-mode="human"] .pmw-chat__role { color: var(--pmw-chat-live, #1a7f37); font-weight: 500; }
55
+ .pmw-chat__notice a { color: var(--pmw-chat-link, #0a1b3d); text-decoration: underline; text-underline-offset: 2px; }
39
56
  .pmw-chat__close {
40
57
  flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%;
41
- display: flex; align-items: center; justify-content: center; background: none; color: inherit;
58
+ display: flex; align-items: center; justify-content: center; background: transparent; color: var(--pmw-chat-fg, #1f2328);
42
59
  }
60
+ .pmw-chat__close:hover { background: var(--pmw-chat-thread-bg, #f7f8fb); }
43
61
  .pmw-chat__close svg { width: 18px; height: 18px; }
44
62
 
45
- .pmw-chat__body { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow-y: auto; padding: 10px 12px; }
46
- .pmw-chat__notice { margin: 0 0 8px; font-size: 14px; color: var(--pmw-chat-muted, #4b5563); }
47
- .pmw-chat__notice a { color: var(--pmw-chat-link, #0a1b3d); text-decoration: underline; text-underline-offset: 2px; }
48
- .pmw-chat__log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
49
- .pmw-chat__msg { max-width: 85%; padding: 8px 12px; border-radius: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
50
- .pmw-chat__msg--bot, .pmw-chat__msg--owner { align-self: flex-start; background: var(--pmw-chat-other-bg, #f1f3f6); color: var(--pmw-chat-fg, #1f2328); border-bottom-left-radius: 4px; }
51
- .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: 4px; }
63
+ /* The thread: a light tint behind the bubbles. A speaker's messages in a row sit 6 px apart, a new
64
+ speaker 10 px down (the list's 6 px gap plus 4 px); the tail corner is on the speaker's side. */
65
+ .pmw-chat__body {
66
+ flex: 1; min-height: 0; display: flex; flex-direction: column; overflow-y: auto; padding: 18px 16px;
67
+ background: var(--pmw-chat-thread-bg, #f7f8fb);
68
+ }
69
+ .pmw-chat__log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
70
+ .pmw-chat__log > li { margin-top: 4px; }
71
+ .pmw-chat__log > li:first-child,
72
+ .pmw-chat__msg--bot + .pmw-chat__msg--bot, .pmw-chat__msg--bot + .pmw-chat__typing,
73
+ .pmw-chat__msg--owner + .pmw-chat__msg--owner, .pmw-chat__msg--owner + .pmw-chat__typing,
74
+ .pmw-chat__msg--visitor + .pmw-chat__msg--visitor { margin-top: 0; }
75
+ .pmw-chat__msg { max-width: 82%; padding: 10px 14px; border-radius: 18px; white-space: pre-wrap; overflow-wrap: anywhere; }
76
+ .pmw-chat__msg--bot, .pmw-chat__msg--owner { align-self: flex-start; border-bottom-left-radius: 6px; color: var(--pmw-chat-fg, #1f2328); }
77
+ .pmw-chat__msg--bot { background: var(--pmw-chat-other-bg, #ffffff); border: 1px solid var(--pmw-chat-line, #e6e9f0); }
78
+ /* A person who took over: a light tint of the accent, so their words never pass for the bot's. */
79
+ .pmw-chat__msg--owner { background: var(--pmw-chat-owner-bg, #eef2fa); border: 1px solid var(--pmw-chat-owner-border, #d5ddef); }
80
+ .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; }
81
+ .pmw-chat__msg--bot + .pmw-chat__msg--bot, .pmw-chat__msg--owner + .pmw-chat__msg--owner { border-top-left-radius: 6px; }
82
+ .pmw-chat__msg--visitor + .pmw-chat__msg--visitor { border-top-right-radius: 6px; }
83
+ .pmw-chat__msg:has(> .pmw-chat__photo) { padding: 4px; }
84
+ .pmw-chat__msg:has(> .pmw-chat__photo) .pmw-chat__text:empty { display: none; }
52
85
  .pmw-chat__msg.is-pending { opacity: 0.75; }
53
- .pmw-chat__msg.is-failed { outline: 2px solid var(--pmw-chat-error, #b42318); }
86
+ .pmw-chat__msg.is-failed { outline: 2px solid var(--pmw-chat-error, #b42318); outline-offset: 2px; }
54
87
  .pmw-chat__retry { display: block; margin-top: 4px; font-size: 14px; }
55
88
  .pmw-chat__retry button { padding: 0; border: 0; background: none; color: inherit; text-decoration: underline; }
56
- .pmw-chat__typing { align-self: flex-start; display: flex; gap: 4px; padding: 12px; border-radius: 12px; background: var(--pmw-chat-other-bg, #f1f3f6); }
57
- .pmw-chat__typing span { width: 6px; height: 6px; border-radius: 50%; background: var(--pmw-chat-muted, #4b5563); animation: pmw-chat-dot 1.2s infinite ease-in-out; }
89
+ .pmw-chat__typing {
90
+ align-self: flex-start; display: flex; gap: 5px; padding: 14px 16px; border-radius: 18px 18px 18px 6px;
91
+ background: var(--pmw-chat-other-bg, #ffffff); border: 1px solid var(--pmw-chat-line, #e6e9f0);
92
+ }
93
+ .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; }
58
94
  .pmw-chat__typing span:nth-child(2) { animation-delay: 0.15s; }
59
95
  .pmw-chat__typing span:nth-child(3) { animation-delay: 0.3s; }
60
96
  @keyframes pmw-chat-dot { 0%, 80%, 100% { opacity: 0.3; } 40% { opacity: 1; } }
61
97
  @media (prefers-reduced-motion: reduce) {
62
98
  .pmw-chat__typing span { animation: none; opacity: 0.7; }
63
99
  }
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 {
102
+ align-self: stretch; margin: 18px 0 8px; display: flex; align-items: center; gap: 10px;
103
+ font-size: 13px; font-weight: 500; line-height: 1.4; color: var(--pmw-chat-muted, #4b5563);
104
+ }
105
+ .pmw-chat__relay::before, .pmw-chat__relay::after { content: ''; flex: 1; min-width: 16px; height: 1px; background: var(--pmw-chat-rule, #cfd5e1); }
106
+ .pmw-chat__relay-label { display: flex; align-items: center; gap: 6px; text-align: center; }
107
+ .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; }
64
109
  .pmw-chat__state { margin: 8px 0 0; font-size: 14px; color: var(--pmw-chat-muted, #4b5563); }
65
110
  .pmw-chat__state:empty { display: none; }
66
- .pmw-chat__ended { margin-top: 12px; }
67
- .pmw-chat__ended p { margin: 0 0 8px; }
111
+ /* The end: a thin line, the sentence centred, an outlined pill. */
112
+ .pmw-chat__ended { margin-top: 22px; display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; }
113
+ .pmw-chat__ended::before { content: ''; align-self: stretch; height: 1px; background: var(--pmw-chat-line, #e6e9f0); }
114
+ .pmw-chat__ended p { margin: 0; font-size: 15px; color: var(--pmw-chat-muted, #4b5563); }
115
+ .pmw-chat__restart {
116
+ min-height: 44px; padding: 0 20px; border-radius: 22px; border: 1px solid var(--pmw-chat-link, #0a1b3d);
117
+ background: var(--pmw-chat-panel-bg, #ffffff); color: var(--pmw-chat-link, #0a1b3d); font-weight: 600;
118
+ }
68
119
 
69
- .pmw-chat__composer { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; padding: 10px 12px; border-top: 1px solid var(--pmw-chat-border, #c9ced6); }
70
- .pmw-chat__composer textarea { flex: 1; min-width: 0; resize: none; }
71
- .pmw-chat__composer textarea, .pmw-chat__field input, .pmw-chat__field textarea {
72
- font: inherit; font-size: 16px; padding: 8px 10px; border-radius: 8px;
120
+ /* The composer: one rounded white field holding the photo button, the text and the send pill. */
121
+ .pmw-chat__composer {
122
+ display: flex; flex-direction: column; gap: 6px; padding: 12px 12px 10px;
123
+ border-top: 1px solid var(--pmw-chat-line, #e6e9f0); background: var(--pmw-chat-panel-bg, #ffffff);
124
+ }
125
+ @media (max-width: 639.98px) {
126
+ .pmw-chat__composer { padding: 10px 12px calc(14px + env(safe-area-inset-bottom, 0px)); }
127
+ }
128
+ .pmw-chat__box {
129
+ display: flex; align-items: flex-end; gap: 6px; padding: 4px; border-radius: 24px;
73
130
  border: 1px solid var(--pmw-chat-field-border, #7d8590); background: #ffffff; color: var(--pmw-chat-field-fg, #1f2328);
74
131
  }
132
+ .pmw-chat__box textarea {
133
+ flex: 1; min-width: 0; box-sizing: border-box; min-height: 44px; max-height: 132px; resize: none; margin: 0;
134
+ padding: 10px 2px; border: 0; border-radius: 0; background: transparent; color: inherit;
135
+ font: inherit; font-size: 16px; line-height: 1.5;
136
+ }
137
+ .pmw-chat__box textarea::placeholder { color: #6b7280; opacity: 1; }
138
+ /* The ring goes round the whole field, not the bare text inside it (where :has() is known). */
139
+ @supports selector(:has(*)) {
140
+ .pmw-chat__box textarea:focus-visible { outline: none; }
141
+ .pmw-chat__box:has(textarea:focus-visible) { outline: 3px solid var(--pmw-chat-focus, #0a1b3d); outline-offset: 2px; }
142
+ }
75
143
  .pmw-chat__send {
76
- min-height: 44px; padding: 8px 16px; border: 0; border-radius: 8px; font-weight: 600;
144
+ flex: none; min-height: 44px; padding: 0 18px; border: 0; border-radius: 22px; font-weight: 600;
77
145
  background: var(--pmw-chat-accent, #0a1b3d); color: var(--pmw-chat-accent-fg, #ffffff);
78
146
  }
79
- .pmw-chat__send:disabled, .pmw-chat__composer textarea:disabled { cursor: not-allowed; opacity: 0.6; }
80
- .pmw-chat__restart { min-height: 44px; padding: 8px 16px; border-radius: 8px; border: 1px solid var(--pmw-chat-link, #0a1b3d); background: none; color: var(--pmw-chat-link, #0a1b3d); font-weight: 600; }
81
- .pmw-chat__composer .pmw-turnstile, .pmw-chat__fallback .pmw-turnstile { flex-basis: 100%; }
147
+ .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; }
82
149
  /* The photo picker: a real file input, visually hidden inside its label, so Tab reaches it and the
83
150
  label shows its focus ring. 44 x 44 like every other control. */
84
151
  .pmw-chat__attach {
85
- flex: none; position: relative; width: 44px; height: 44px; border-radius: 8px; cursor: pointer;
86
- display: flex; align-items: center; justify-content: center;
87
- border: 1px solid var(--pmw-chat-field-border, #7d8590); color: var(--pmw-chat-field-fg, #1f2328); background: #ffffff;
152
+ flex: none; position: relative; width: 44px; height: 44px; border-radius: 50%; cursor: pointer;
153
+ display: flex; align-items: center; justify-content: center; color: inherit;
88
154
  }
155
+ .pmw-chat__attach:hover { background: #eef0f4; }
89
156
  .pmw-chat__attach svg { width: 22px; height: 22px; }
90
157
  .pmw-chat__attach:focus-within { outline: 3px solid var(--pmw-chat-focus, #0a1b3d); outline-offset: 2px; }
91
- .pmw-chat__hint { flex-basis: 100%; margin: 0; font-size: 14px; color: var(--pmw-chat-muted, #4b5563); }
92
- .pmw-chat__composer .pmw-chat__photo-error { flex-basis: 100%; }
93
- .pmw-chat__photo { display: block; max-width: 100%; max-height: 220px; border-radius: 8px; }
158
+ .pmw-chat__hint { margin: 0 8px; font-size: 13px; line-height: 1.4; color: var(--pmw-chat-muted, #4b5563); }
159
+ .pmw-chat__composer .pmw-chat__photo-error { margin: 0 8px; }
160
+ .pmw-chat__photo { display: block; max-width: 100%; max-height: 220px; border-radius: 14px; }
94
161
 
95
- .pmw-chat__fallback { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
96
- .pmw-chat__fb-title { margin: 0; font-weight: 600; }
97
- .pmw-chat__fb-intro { margin: 0; font-size: 15px; }
162
+ /* 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; }
164
+ .pmw-chat__fb-title { margin: 0; font-size: 20px; font-weight: 600; line-height: 1.3; }
165
+ .pmw-chat__fb-intro { margin: -8px 0 0; font-size: 15px; color: var(--pmw-chat-muted, #4b5563); }
98
166
  .pmw-chat__field { display: flex; flex-direction: column; gap: 4px; }
99
167
  .pmw-chat__field label { font-size: 14px; font-weight: 600; }
168
+ .pmw-chat__field input, .pmw-chat__field textarea {
169
+ font: inherit; font-size: 16px; padding: 10px 14px; border-radius: 12px; resize: none;
170
+ border: 1px solid var(--pmw-chat-field-border, #7d8590); background: #ffffff; color: var(--pmw-chat-field-fg, #1f2328);
171
+ }
172
+ .pmw-chat__fallback .pmw-chat__send { align-self: stretch; min-height: 48px; border-radius: 24px; }
100
173
  .pmw-chat__error { margin: 0; font-size: 14px; color: var(--pmw-chat-error, #b42318); }
101
174
  .pmw-chat__error:empty { display: none; }
102
175
  .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; }
package/src/widget.js CHANGED
@@ -20,7 +20,8 @@
20
20
  // « Non envoyé » with « Réessayer ».
21
21
  //
22
22
  // The mode comes from the service: `human` shows the owner's name, « Vous répond en direct » and the
23
- // green dot; `bot` shows « Assistant virtuel » and no dot (spec § 6: a dot says a person is here).
23
+ // green dot; `bot` shows « Assistant virtuel », the automated notice and no dot (spec § 6: a dot
24
+ // says a person is here). Where a person takes over, the thread shows a line saying so (relay()).
24
25
  // `fallback` (daily cap, model down) and a refused start show the contact form, which posts the
25
26
  // same lead through /api/chat/start. The `lead` frame fires window.pmw?.event('chat-lead') once.
26
27
  //
@@ -51,7 +52,7 @@ export const PHOTO_QUALITY = 0.85
51
52
  const TYPING_SEND_MS = 3000
52
53
  const TYPING_SHOW_MS = 8000
53
54
 
54
- const CAMERA_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 8h3l2-3h6l2 3h3v11H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="2"/></svg>'
55
+ const PHOTO_ICON = '<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>'
55
56
 
56
57
  /** The longest edge to PHOTO_EDGE, re-encoded as JPEG: the canvas drops every EXIF field. */
57
58
  async function shrinkPhoto(win, file) {
@@ -139,17 +140,22 @@ export function mountChat(root, deps = {}) {
139
140
  const who = el(doc, 'div', { class: 'pmw-chat__who' })
140
141
  const name = el(doc, 'p', { class: 'pmw-chat__name', id: 'pmw-chat-name' })
141
142
  const role = el(doc, 'p', { class: 'pmw-chat__role' })
143
+ // The green dot sits on the face's corner, so the face gets a frame the dot is placed in (the
144
+ // avatar itself clips its content to a circle).
145
+ const face = el(doc, 'span', { class: 'pmw-chat__face' })
142
146
  const dot = el(doc, 'span', { class: 'pmw-chat__dot', 'aria-hidden': 'true', hidden: true })
143
147
  const closeBtn = el(doc, 'button', { type: 'button', class: 'pmw-chat__close', 'aria-label': s.close })
144
148
  closeBtn.innerHTML = X_ICON
145
- who.append(name, role)
146
- head.append(avatar, who, dot, closeBtn)
147
-
149
+ // The automated notice and the privacy link live under the name, in bot mode (a person who took
150
+ // over is not « réponses automatiques »).
148
151
  const notice = el(doc, 'p', { class: 'pmw-chat__notice' }, s.automated)
149
152
  if (cfg.privacyHref) {
150
153
  notice.append(' ')
151
154
  notice.append(el(doc, 'a', { href: cfg.privacyHref }, s.privacy))
152
155
  }
156
+ face.append(avatar, dot)
157
+ who.append(name, role, notice)
158
+ head.append(face, who, closeBtn)
153
159
  const log = el(doc, 'ol', { class: 'pmw-chat__log' })
154
160
  const greeting = el(doc, 'li', { class: 'pmw-chat__msg pmw-chat__msg--bot pmw-chat__msg--greeting' }, opener)
155
161
  const typing = el(doc, 'li', { class: 'pmw-chat__typing', 'aria-hidden': 'true', hidden: true })
@@ -163,9 +169,12 @@ export function mountChat(root, deps = {}) {
163
169
  'data-language': lang, 'data-appearance': 'interaction-only', 'data-size': 'flexible',
164
170
  })
165
171
  const composer = el(doc, 'form', { class: 'pmw-chat__composer', novalidate: true })
166
- const input = el(doc, 'textarea', { id: 'pmw-chat-input', name: 'message', rows: '2', maxlength: String(MAX_TEXT), autocomplete: 'off', disabled: demo, 'aria-describedby': demo ? 'pmw-chat-demo-hint' : null })
172
+ 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 })
167
173
  const send = el(doc, 'button', { type: 'submit', class: 'pmw-chat__send', disabled: demo }, s.send)
168
- composer.append(el(doc, 'label', { class: 'pmw-chat__sr', for: 'pmw-chat-input' }, s.messageLabel), input, send)
174
+ // One rounded field holds the photo button, the text and the send button; hints and errors go under it.
175
+ const box = el(doc, 'div', { class: 'pmw-chat__box' })
176
+ box.append(el(doc, 'label', { class: 'pmw-chat__sr', for: 'pmw-chat-input' }, s.messageLabel), input, send)
177
+ composer.append(box)
169
178
  if (demo) composer.append(el(doc, 'p', { class: 'pmw-chat__hint', id: 'pmw-chat-demo-hint' }, s.demoHint))
170
179
  else composer.append(widget(composer))
171
180
  let picker = null
@@ -173,10 +182,10 @@ export function mountChat(root, deps = {}) {
173
182
  if (cfg.photos === true) {
174
183
  const attach = el(doc, 'label', { class: 'pmw-chat__attach' })
175
184
  picker = el(doc, 'input', { type: 'file', class: 'pmw-chat__sr', accept: 'image/*', multiple: true, 'aria-describedby': 'pmw-chat-photo-hint', disabled: demo })
176
- attach.innerHTML = CAMERA_ICON
185
+ attach.innerHTML = PHOTO_ICON
177
186
  attach.prepend(picker)
178
187
  attach.append(el(doc, 'span', { class: 'pmw-chat__sr' }, s.attachPhoto))
179
- composer.insertBefore(attach, input)
188
+ box.insertBefore(attach, input)
180
189
  composer.append(el(doc, 'p', { class: 'pmw-chat__hint', id: 'pmw-chat-photo-hint' }, s.photoHint), photoError)
181
190
  }
182
191
 
@@ -203,7 +212,7 @@ export function mountChat(root, deps = {}) {
203
212
  )
204
213
  if (!demo) fallback.append(widget(fallback))
205
214
  const body = el(doc, 'div', { class: 'pmw-chat__body' })
206
- body.append(notice, log, state, ended, fallback)
215
+ body.append(log, state, ended, fallback)
207
216
  panel.append(head, body, composer, announcer)
208
217
  root.append(panel)
209
218
 
@@ -236,6 +245,7 @@ export function mountChat(root, deps = {}) {
236
245
  name.textContent = human ? (ownerName || s.assistant) : (botName() || s.assistant)
237
246
  role.textContent = human ? s.live : (botName() ? s.assistant : '')
238
247
  role.hidden = role.textContent === ''
248
+ notice.hidden = human
239
249
  dot.hidden = !human
240
250
  panel.dataset.mode = mode
241
251
  }
@@ -253,6 +263,25 @@ export function mountChat(root, deps = {}) {
253
263
  log.insertBefore(li, typing.parentNode === log ? typing : null)
254
264
  return li
255
265
  }
266
+ // Where a person takes over, one line in the thread: « {name} a pris le relais ». It goes before
267
+ // the first owner message that follows the bot (live, and when the thread is replayed after a
268
+ // reload), or at the end of the thread when the mode turns human before the person writes. The
269
+ // scan back stops at a line already there or at an owner message, so one takeover gets one line
270
+ // however often the frames repeat it; the bot answering again and a person taking over again
271
+ // 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 })
280
+ 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)
282
+ sep.append(label)
283
+ log.insertBefore(sep, at ?? (typing.parentNode === log ? typing : null))
284
+ }
256
285
  const find = (id) => [...log.querySelectorAll('li[data-id]')].find((li) => li.dataset.id === id)
257
286
  const setText = (li, m, text) => {
258
287
  li.firstChild.textContent = fill(s.said, { name: speaker(m), text: '' })
@@ -268,7 +297,10 @@ export function mountChat(root, deps = {}) {
268
297
  seen.add(m.seq)
269
298
  lastSeq = Math.max(lastSeq, m.seq)
270
299
  let li = find(m.id)
271
- if (!li) li = line(m.id, m.role)
300
+ if (!li) {
301
+ li = line(m.id, m.role)
302
+ if (m.role === 'owner') relay(li, m.by || ownerName)
303
+ }
272
304
  li.classList.remove('is-streaming', 'is-pending', 'is-failed')
273
305
  li.querySelector('.pmw-chat__retry')?.remove()
274
306
  setText(li, m, m.text)
@@ -381,11 +413,13 @@ export function mountChat(root, deps = {}) {
381
413
  mode = m.mode === 'human' ? 'human' : 'bot'
382
414
  ownerName = typeof m.ownerName === 'string' ? m.ownerName : null
383
415
  header()
416
+ if (mode === 'human') relay(null, ownerName)
384
417
  if (m.closed) end(s.ended, true)
385
418
  }
386
419
  function frame(m) {
387
420
  switch (m?.type) {
388
421
  case 'history':
422
+ if (typeof m.ownerName === 'string') ownerName = m.ownerName
389
423
  for (const x of m.messages ?? []) render(x, { announce: false })
390
424
  return status(m)
391
425
  case 'message':
@@ -455,7 +489,7 @@ export function mountChat(root, deps = {}) {
455
489
  if (r.error) return showState(r.error)
456
490
  showState('')
457
491
  // 409 cap: the visitor's conversations for the day are used up (pmeweb-chat counts them per IP).
458
- showFallback(r.cap ? s.fallbackCap : s.unavailable)
492
+ showFallback(r.cap ? s.fallbackCap : s.fallbackDown)
459
493
  })
460
494
  return starting
461
495
  }
@@ -540,6 +574,7 @@ export function mountChat(root, deps = {}) {
540
574
  const text = input.value.trim()
541
575
  if (!text || over || demo) return
542
576
  input.value = ''
577
+ grow()
543
578
  const entry = { id: uuid(), text: text.slice(0, MAX_TEXT), tries: 0, failed: false }
544
579
  entry.li = line(entry.id, 'visitor')
545
580
  entry.li.classList.add('is-pending')
@@ -554,7 +589,13 @@ export function mountChat(root, deps = {}) {
554
589
  input.addEventListener('keydown', (e) => {
555
590
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit() }
556
591
  })
592
+ // The field grows with the text, up to about five lines, then scrolls.
593
+ const grow = () => {
594
+ input.style.height = 'auto'
595
+ input.style.height = input.value ? `${Math.min(input.scrollHeight, 132)}px` : ''
596
+ }
557
597
  input.addEventListener('input', () => {
598
+ grow()
558
599
  const now = Date.now()
559
600
  if (isOpen() && now - lastTyping > TYPING_SEND_MS) {
560
601
  lastTyping = now
@@ -575,7 +616,7 @@ export function mountChat(root, deps = {}) {
575
616
  mode = 'bot'
576
617
  ownerName = null
577
618
  header()
578
- for (const li of [...log.querySelectorAll('li[data-id]')]) li.remove()
619
+ for (const li of [...log.querySelectorAll('li[data-id], .pmw-chat__relay')]) li.remove()
579
620
  ended.hidden = true
580
621
  composer.hidden = false
581
622
  input.focus()
package/src/worker.d.ts CHANGED
@@ -25,8 +25,6 @@ export interface ChatEnv {
25
25
  TURNSTILE_SECRET?: string
26
26
  /** A Workers Rate Limiting binding (`ratelimits` in wrangler.jsonc), keyed `chat:<ip>`. */
27
27
  CHAT_LIMIT?: { limit(o: { key: string }): Promise<{ success: boolean }> }
28
- /** The conversation-start limit (3 a minute), keyed `chat-start:<ip>`; absent, starts use CHAT_LIMIT. */
29
- CHAT_START_LIMIT?: { limit(o: { key: string }): Promise<{ success: boolean }> }
30
28
  }
31
29
 
32
30
  /**
@@ -58,5 +56,5 @@ export declare const RPC_VERSION: number
58
56
  export declare function health(env: Partial<ChatEnv>, opts?: { host?: string; config?: ChatConfig | Record<string, unknown> }): Promise<{
59
57
  version: string
60
58
  rpc: number
61
- checks: { secret: boolean; binding: boolean; limit: boolean; startLimit: boolean; rpc: boolean; settings: boolean } | { disabled: true }
59
+ checks: { secret: boolean; binding: boolean; limit: boolean; rpc: boolean; settings: boolean } | { disabled: true }
62
60
  }>
package/src/worker.js CHANGED
@@ -9,10 +9,9 @@
9
9
  // workers.dev address. Without `hosts`, every request is off (fail closed).
10
10
  // - The host sent to pmeweb-chat is the one this request ARRIVED on, never a header or a body
11
11
  // field: it is how pmeweb-chat finds the client's settings.
12
- // - start: Turnstile (@pmeweb/turnstile, action `chat`, solved on this host), then CHAT_START_LIMIT
13
- // (3 a minute) keyed on the visitor's IP, then pmeweb-chat. The contact form's start
14
- // (`fallbackLead`) is on the looser CHAT_LIMIT instead, so a visitor refused a conversation can
15
- // still leave a lead; a site that has not bound CHAT_START_LIMIT yet falls back to CHAT_LIMIT.
12
+ // - start: Turnstile (@pmeweb/turnstile, action `chat`, solved on this host), then CHAT_LIMIT keyed
13
+ // on the visitor's IP, then pmeweb-chat, which applies the global limits (conversation starts per
14
+ // minute, conversations per day, messages, the daily cap; edited in the portal admin since 1.2.0).
16
15
  // The IP goes to siteverify, the limiters and, in the `x-pmw-visitor-ip` header only, to
17
16
  // pmeweb-chat, which counts conversations per visitor per day (10) and keeps only a daily hash
18
17
  // of it. pmeweb-chat trusts that header because only service bindings reach it.
@@ -33,7 +32,7 @@ import { verifyTurnstile } from '@pmeweb/turnstile/worker'
33
32
  import { RPC_HEADER, RPC_VERSION } from './protocol.js'
34
33
 
35
34
  /** This package's version, reported by the health route (a test holds it equal to package.json). */
36
- export const VERSION = '1.1.0'
35
+ export const VERSION = '1.2.0'
37
36
  export { RPC_VERSION }
38
37
 
39
38
  const SERVICE = 'https://pmeweb-chat.internal'
@@ -59,9 +58,9 @@ const langOf = (v) => (v === 'en' || v === 'fr' ? v : null)
59
58
 
60
59
  const VISITOR_IP_HEADER = 'x-pmw-visitor-ip'
61
60
 
62
- /** CHAT_LIMIT by default (sockets, photos, the contact form); `start` uses CHAT_START_LIMIT when bound. */
61
+ /** CHAT_LIMIT, the flood guard at the site's edge (starts, sockets, photos, the contact form). */
63
62
  async function allowed(request, env, { start = false } = {}) {
64
- const [limiter, prefix] = start && env.CHAT_START_LIMIT ? [env.CHAT_START_LIMIT, 'chat-start'] : [env.CHAT_LIMIT, 'chat']
63
+ const [limiter, prefix] = [env.CHAT_LIMIT, 'chat']
65
64
  if (!limiter) return true
66
65
  try {
67
66
  return (await limiter.limit({ key: `${prefix}:${request.headers.get('cf-connecting-ip') ?? 'unknown'}` })).success
@@ -265,7 +264,6 @@ export async function health(env, { host, config } = {}) {
265
264
  secret: !!env.TURNSTILE_SECRET,
266
265
  binding: !!env.CHAT,
267
266
  limit: !!env.CHAT_LIMIT,
268
- startLimit: !!env.CHAT_START_LIMIT,
269
267
  rpc,
270
268
  settings: rpc && !!host && answer.configured === true,
271
269
  },