@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 +9 -10
- package/README.md +52 -15
- package/dist/trigger.iife.js +1 -1
- package/dist/widget.js +2 -2
- package/manifest.json +3 -7
- package/package.json +1 -1
- package/src/Chat.astro +3 -0
- package/src/chat.css +36 -1
- package/src/colors.js +37 -13
- package/src/strings.js +16 -12
- package/src/trigger.js +25 -8
- package/src/widget.css +120 -47
- package/src/widget.js +54 -13
- package/src/worker.d.ts +1 -3
- package/src/worker.js +6 -8
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
**Updating from 1.
|
|
47
|
-
|
|
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
|
|
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`
|
|
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
|
|
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.**
|
|
54
|
-
|
|
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
|
|
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
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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` (#
|
|
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
|
-
(#
|
|
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`
|
|
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 (
|
|
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`).
|
package/dist/trigger.iife.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
(()=>{function
|
|
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
|
|
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":
|
|
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.
|
|
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,
|
|
71
|
-
* - primary: the
|
|
72
|
-
* - surface: the panel and the
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
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':
|
|
91
|
-
'--pmw-chat-
|
|
92
|
-
'--pmw-chat-
|
|
93
|
-
'--pmw-chat-
|
|
94
|
-
'--pmw-chat-
|
|
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: '
|
|
31
|
-
unavailable: '
|
|
32
|
-
turnstile: '
|
|
33
|
-
tooMany: '
|
|
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: '
|
|
36
|
-
fallbackDown: '
|
|
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: '
|
|
76
|
-
unavailable: '
|
|
77
|
-
turnstile: '
|
|
78
|
-
tooMany: '
|
|
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: '
|
|
81
|
-
fallbackDown: '
|
|
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
|
-
|
|
47
|
-
const on = (t, ev, f) =>
|
|
48
|
-
|
|
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
|
-
|
|
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 #
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 -
|
|
21
|
-
border: 1px solid var(--pmw-chat-
|
|
22
|
-
box-shadow: 0
|
|
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-
|
|
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:
|
|
31
|
-
|
|
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-
|
|
34
|
-
.pmw-chat__head .pmw-chat__avatar
|
|
35
|
-
.pmw-
|
|
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:
|
|
38
|
-
.pmw-
|
|
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:
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
.pmw-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.pmw-
|
|
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 {
|
|
57
|
-
|
|
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
|
-
|
|
67
|
-
.pmw-chat__ended
|
|
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
|
-
|
|
70
|
-
.pmw-chat__composer
|
|
71
|
-
|
|
72
|
-
|
|
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:
|
|
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-
|
|
80
|
-
.pmw-
|
|
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:
|
|
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 {
|
|
92
|
-
.pmw-chat__composer .pmw-chat__photo-error {
|
|
93
|
-
.pmw-chat__photo { display: block; max-width: 100%; max-height: 220px; border-radius:
|
|
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
|
-
|
|
96
|
-
.pmw-
|
|
97
|
-
.pmw-chat__fb-
|
|
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
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
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: '
|
|
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
|
-
|
|
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 =
|
|
185
|
+
attach.innerHTML = PHOTO_ICON
|
|
177
186
|
attach.prepend(picker)
|
|
178
187
|
attach.append(el(doc, 'span', { class: 'pmw-chat__sr' }, s.attachPhoto))
|
|
179
|
-
|
|
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(
|
|
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)
|
|
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.
|
|
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;
|
|
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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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.
|
|
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
|
|
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] =
|
|
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
|
},
|