@pmeweb/plugin-chat 1.0.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 +13 -5
- package/README.md +67 -16
- package/dist/trigger.iife.js +1 -1
- package/dist/widget.js +2 -2
- package/manifest.json +23 -8
- 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 +44 -11
- package/src/widget.css +120 -47
- package/src/widget.js +67 -16
- package/src/worker.js +19 -8
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
|
@@ -4,17 +4,31 @@
|
|
|
4
4
|
// onInteraction: press, hover, focus, key) and opened on a click. Off the site's real hosts the
|
|
5
5
|
// panel runs its demo (manifest preview: test); the widget decides that, not this file.
|
|
6
6
|
//
|
|
7
|
+
// THE DEMO FOLLOWS THE URL FRAGMENT, off the real hosts only (phase 4 spec 2.4), so the portal's
|
|
8
|
+
// preview switches views without a rebuild: `#pmw-chat=panel` imports the widget at once and opens
|
|
9
|
+
// the panel, `#pmw-chat=bubble` closes it; read on load and on `hashchange`. The name
|
|
10
|
+
// (`pmw-chat-botName`) is the widget's to read. No opener is scheduled there: it would cover the
|
|
11
|
+
// preview the portal is showing.
|
|
12
|
+
//
|
|
7
13
|
// The opener shows once per visit (sessionStorage), on the first of: 20 s on the page, 30 % of the
|
|
8
14
|
// page scrolled, or exit intent (a fine pointer leaving through the top of the window). Never while
|
|
9
|
-
// the panel is open, never again once dismissed or once the panel has been opened
|
|
10
|
-
|
|
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.
|
|
24
|
+
import { isCanonical, onInteraction as interact } from '@pmeweb/loader'
|
|
11
25
|
|
|
12
26
|
export const OPENER_KEY = 'pmw-chat-opener'
|
|
13
27
|
export const OPENER_DELAY_MS = 20000
|
|
14
28
|
export const OPENER_SCROLL = 0.3
|
|
15
29
|
|
|
16
30
|
export function startChat({
|
|
17
|
-
doc = document, win = window, importLazy = (u) => import(/* @vite-ignore */ u), onInteraction = interact,
|
|
31
|
+
doc = document, win = window, hostname = win.location.hostname, importLazy = (u) => import(/* @vite-ignore */ u), onInteraction = interact,
|
|
18
32
|
} = {}) {
|
|
19
33
|
const root = doc.getElementById('pmw-chat')
|
|
20
34
|
if (!root) return null
|
|
@@ -23,21 +37,40 @@ export function startChat({
|
|
|
23
37
|
let widget = null
|
|
24
38
|
const load = () => (widget ??= importLazy(root.dataset.pmwLazy).then((m) => m.mountChat(root), (e) => { widget = null; throw e }))
|
|
25
39
|
onInteraction(root, () => load().catch(() => {}))
|
|
40
|
+
const canonical = isCanonical({ doc, hostname })
|
|
41
|
+
if (!canonical) {
|
|
42
|
+
const view = () => {
|
|
43
|
+
const v = new URLSearchParams(win.location.hash.slice(1)).get('pmw-chat')
|
|
44
|
+
if (v === 'panel' && !root.hasAttribute('data-open')) load().then((w) => w.open(), () => {})
|
|
45
|
+
else if (v === 'bubble' && widget) widget.then((w) => w.close(), () => {})
|
|
46
|
+
}
|
|
47
|
+
view()
|
|
48
|
+
win.addEventListener('hashchange', view)
|
|
49
|
+
}
|
|
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
|
+
}
|
|
26
61
|
|
|
27
62
|
const store = win.sessionStorage
|
|
28
63
|
let done = false
|
|
29
64
|
try { done = !!store?.getItem(OPENER_KEY) } catch { /* storage blocked: show once per page */ }
|
|
30
|
-
|
|
31
|
-
const on = (t, ev, f) =>
|
|
32
|
-
|
|
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.
|
|
33
68
|
const finish = () => {
|
|
34
69
|
done = true
|
|
35
70
|
try { store?.setItem(OPENER_KEY, '1') } catch { /* nothing to remember with */ }
|
|
36
|
-
win.clearTimeout(timer)
|
|
37
|
-
for (const [t, ev, f] of listeners) t.removeEventListener(ev, f)
|
|
38
71
|
}
|
|
39
72
|
const show = () => {
|
|
40
|
-
if (done || !teaser || root.hasAttribute('data-open')) return
|
|
73
|
+
if (done || away || !teaser || root.hasAttribute('data-open')) return
|
|
41
74
|
finish()
|
|
42
75
|
teaser.hidden = false
|
|
43
76
|
}
|
|
@@ -48,8 +81,8 @@ export function startChat({
|
|
|
48
81
|
finish()
|
|
49
82
|
if (t.hasAttribute('data-pmw-chat-open')) load().then((w) => w.open(), () => {})
|
|
50
83
|
})
|
|
51
|
-
if (!done && teaser) {
|
|
52
|
-
|
|
84
|
+
if (!done && teaser && canonical) {
|
|
85
|
+
win.setTimeout(show, OPENER_DELAY_MS)
|
|
53
86
|
on(win, 'scroll', () => {
|
|
54
87
|
const room = doc.documentElement.scrollHeight - win.innerHeight
|
|
55
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
|
|
|
@@ -227,12 +236,16 @@ export function mountChat(root, deps = {}) {
|
|
|
227
236
|
const photosSent = new Set()
|
|
228
237
|
let photosBusy = 0
|
|
229
238
|
|
|
230
|
-
|
|
239
|
+
// The demo's name is the portal preview's, from the fragment (phase 4 spec 2.4); never on a real host.
|
|
240
|
+
const fragmentName = () => new URLSearchParams(win.location.hash.slice(1)).get('pmw-chat-botName')?.trim() || null
|
|
241
|
+
let demoName = demo ? fragmentName() : null
|
|
242
|
+
const botName = () => (demo ? demoName : session?.botName || null)
|
|
231
243
|
function header() {
|
|
232
244
|
const human = mode === 'human'
|
|
233
245
|
name.textContent = human ? (ownerName || s.assistant) : (botName() || s.assistant)
|
|
234
246
|
role.textContent = human ? s.live : (botName() ? s.assistant : '')
|
|
235
247
|
role.hidden = role.textContent === ''
|
|
248
|
+
notice.hidden = human
|
|
236
249
|
dot.hidden = !human
|
|
237
250
|
panel.dataset.mode = mode
|
|
238
251
|
}
|
|
@@ -250,6 +263,25 @@ export function mountChat(root, deps = {}) {
|
|
|
250
263
|
log.insertBefore(li, typing.parentNode === log ? typing : null)
|
|
251
264
|
return li
|
|
252
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
|
+
}
|
|
253
285
|
const find = (id) => [...log.querySelectorAll('li[data-id]')].find((li) => li.dataset.id === id)
|
|
254
286
|
const setText = (li, m, text) => {
|
|
255
287
|
li.firstChild.textContent = fill(s.said, { name: speaker(m), text: '' })
|
|
@@ -265,7 +297,10 @@ export function mountChat(root, deps = {}) {
|
|
|
265
297
|
seen.add(m.seq)
|
|
266
298
|
lastSeq = Math.max(lastSeq, m.seq)
|
|
267
299
|
let li = find(m.id)
|
|
268
|
-
if (!li)
|
|
300
|
+
if (!li) {
|
|
301
|
+
li = line(m.id, m.role)
|
|
302
|
+
if (m.role === 'owner') relay(li, m.by || ownerName)
|
|
303
|
+
}
|
|
269
304
|
li.classList.remove('is-streaming', 'is-pending', 'is-failed')
|
|
270
305
|
li.querySelector('.pmw-chat__retry')?.remove()
|
|
271
306
|
setText(li, m, m.text)
|
|
@@ -294,7 +329,13 @@ export function mountChat(root, deps = {}) {
|
|
|
294
329
|
const scroll = () => { log.scrollTop = log.scrollHeight }
|
|
295
330
|
if (demo) {
|
|
296
331
|
setText(line('demo-question', 'visitor'), { role: 'visitor' }, demoQuestion(opener, lang))
|
|
297
|
-
|
|
332
|
+
const answer = line('demo-answer', 'bot')
|
|
333
|
+
setText(answer, { role: 'bot' }, s.demoAnswer)
|
|
334
|
+
win.addEventListener('hashchange', () => {
|
|
335
|
+
demoName = fragmentName()
|
|
336
|
+
header()
|
|
337
|
+
setText(answer, { role: 'bot' }, s.demoAnswer)
|
|
338
|
+
})
|
|
298
339
|
}
|
|
299
340
|
|
|
300
341
|
function failed(entry) {
|
|
@@ -372,11 +413,13 @@ export function mountChat(root, deps = {}) {
|
|
|
372
413
|
mode = m.mode === 'human' ? 'human' : 'bot'
|
|
373
414
|
ownerName = typeof m.ownerName === 'string' ? m.ownerName : null
|
|
374
415
|
header()
|
|
416
|
+
if (mode === 'human') relay(null, ownerName)
|
|
375
417
|
if (m.closed) end(s.ended, true)
|
|
376
418
|
}
|
|
377
419
|
function frame(m) {
|
|
378
420
|
switch (m?.type) {
|
|
379
421
|
case 'history':
|
|
422
|
+
if (typeof m.ownerName === 'string') ownerName = m.ownerName
|
|
380
423
|
for (const x of m.messages ?? []) render(x, { announce: false })
|
|
381
424
|
return status(m)
|
|
382
425
|
case 'message':
|
|
@@ -428,7 +471,7 @@ export function mountChat(root, deps = {}) {
|
|
|
428
471
|
try { turnstile.reset(form) } catch { /* a token is single-use; the next post asks again */ }
|
|
429
472
|
if (res?.ok && data) return { ok: true, data }
|
|
430
473
|
const error = res?.status === 403 ? s.turnstile : res?.status === 429 ? s.tooMany : null
|
|
431
|
-
return { ok: false, error }
|
|
474
|
+
return { ok: false, error, cap: res?.status === 409 && data?.error === 'cap' }
|
|
432
475
|
}
|
|
433
476
|
function begin() {
|
|
434
477
|
if (starting || session) return starting
|
|
@@ -445,7 +488,8 @@ export function mountChat(root, deps = {}) {
|
|
|
445
488
|
for (const entry of pending.values()) failed(entry)
|
|
446
489
|
if (r.error) return showState(r.error)
|
|
447
490
|
showState('')
|
|
448
|
-
|
|
491
|
+
// 409 cap: the visitor's conversations for the day are used up (pmeweb-chat counts them per IP).
|
|
492
|
+
showFallback(r.cap ? s.fallbackCap : s.fallbackDown)
|
|
449
493
|
})
|
|
450
494
|
return starting
|
|
451
495
|
}
|
|
@@ -530,6 +574,7 @@ export function mountChat(root, deps = {}) {
|
|
|
530
574
|
const text = input.value.trim()
|
|
531
575
|
if (!text || over || demo) return
|
|
532
576
|
input.value = ''
|
|
577
|
+
grow()
|
|
533
578
|
const entry = { id: uuid(), text: text.slice(0, MAX_TEXT), tries: 0, failed: false }
|
|
534
579
|
entry.li = line(entry.id, 'visitor')
|
|
535
580
|
entry.li.classList.add('is-pending')
|
|
@@ -544,7 +589,13 @@ export function mountChat(root, deps = {}) {
|
|
|
544
589
|
input.addEventListener('keydown', (e) => {
|
|
545
590
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit() }
|
|
546
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
|
+
}
|
|
547
597
|
input.addEventListener('input', () => {
|
|
598
|
+
grow()
|
|
548
599
|
const now = Date.now()
|
|
549
600
|
if (isOpen() && now - lastTyping > TYPING_SEND_MS) {
|
|
550
601
|
lastTyping = now
|
|
@@ -565,7 +616,7 @@ export function mountChat(root, deps = {}) {
|
|
|
565
616
|
mode = 'bot'
|
|
566
617
|
ownerName = null
|
|
567
618
|
header()
|
|
568
|
-
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()
|
|
569
620
|
ended.hidden = true
|
|
570
621
|
composer.hidden = false
|
|
571
622
|
input.focus()
|