@pmeweb/plugin-chat 0.0.0-stage → 1.0.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/src/strings.js ADDED
@@ -0,0 +1,114 @@
1
+ // Every word the chat shows, in one place: the component renders the bubble's words from here at
2
+ // build, and the lazy widget renders the panel's. A site may override any string through the
3
+ // component's `strings` prop (merged over these). French follows OQLF typography: a non-breaking
4
+ // space (U+00A0) before a colon, none before ; ? !, and the apostrophe ’.
5
+ //
6
+ // The bubble always says « Assistant virtuel » in text (spec § 6, never passing for a person); its
7
+ // accessible name STARTS with that visible text (WCAG 2.5.3, label in name).
8
+
9
+ const NBSP = ' '
10
+
11
+ export const STRINGS = {
12
+ fr: {
13
+ assistant: 'Assistant virtuel',
14
+ openLabel: 'Assistant virtuel, ouvrir le clavardage',
15
+ dismissTeaser: 'Masquer ce message',
16
+ close: 'Fermer le clavardage',
17
+ panelTitle: 'Clavardage',
18
+ automated: 'Réponses automatiques, tirées du contenu de ce site.',
19
+ privacy: 'Confidentialité',
20
+ you: 'Vous',
21
+ messageLabel: 'Votre message',
22
+ send: 'Envoyer',
23
+ live: 'Vous répond en direct',
24
+ connecting: 'Connexion…',
25
+ reconnecting: 'Connexion perdue, reconnexion…',
26
+ notSent: 'Non envoyé.',
27
+ retry: 'Réessayer',
28
+ ended: 'Cette conversation est terminée.',
29
+ newConversation: 'Nouvelle conversation',
30
+ reload: 'Le clavardage a été mis à jour. Rechargez la page pour continuer.',
31
+ unavailable: 'Le clavardage n’est pas disponible pour le moment.',
32
+ turnstile: 'La vérification anti-robot n’a pas abouti. Réessayez.',
33
+ tooMany: 'Trop de messages en peu de temps. Réessayez dans une minute.',
34
+ fallbackTitle: 'Laissez-nous vos coordonnées',
35
+ fallbackCap: 'L’assistant ne peut plus répondre aujourd’hui. Laissez vos coordonnées et on vous recontactera.',
36
+ fallbackDown: 'L’assistant ne peut pas répondre pour le moment. Laissez vos coordonnées et on vous recontactera.',
37
+ name: 'Votre nom',
38
+ contact: 'Téléphone ou courriel',
39
+ need: 'Votre demande',
40
+ fallbackSend: 'Envoyer la demande',
41
+ fallbackSent: 'Merci, votre demande a été transmise.',
42
+ fallbackMissing: 'Remplissez les trois champs.',
43
+ said: `{name}${NBSP}: {text}`,
44
+ attachPhoto: 'Joindre une photo',
45
+ photoHint: 'Évitez les photos de personnes ou de pièces d’identité.',
46
+ photoAlt: 'Votre photo',
47
+ photoSent: 'Photo envoyée',
48
+ photoLimit: 'Trois photos au plus par conversation.',
49
+ photoTooLarge: 'Cette photo est trop lourde.',
50
+ photoUnreadable: 'Ce fichier n’est pas une photo lisible.',
51
+ photoFailed: 'La photo n’a pas pu être envoyée. Réessayez.',
52
+ demoAsk: 'Pouvez-vous m’en dire plus sur {topic}?',
53
+ demoQuestion: 'Quels sont vos délais?',
54
+ demoAnswer: '(Démonstration) Ici, l’assistant répond à partir du contenu de votre site.',
55
+ demoHint: `Aperçu${NBSP}: la conversation est désactivée.`,
56
+ },
57
+ en: {
58
+ assistant: 'Virtual assistant',
59
+ openLabel: 'Virtual assistant, open the chat',
60
+ dismissTeaser: 'Hide this message',
61
+ close: 'Close the chat',
62
+ panelTitle: 'Chat',
63
+ automated: 'Automatic answers, drawn from this site’s content.',
64
+ privacy: 'Privacy',
65
+ you: 'You',
66
+ messageLabel: 'Your message',
67
+ send: 'Send',
68
+ live: 'Replying live',
69
+ connecting: 'Connecting…',
70
+ reconnecting: 'Connection lost, reconnecting…',
71
+ notSent: 'Not sent.',
72
+ retry: 'Retry',
73
+ ended: 'This conversation has ended.',
74
+ newConversation: 'New conversation',
75
+ reload: 'The chat was updated. Reload the page to continue.',
76
+ unavailable: 'The chat is not available right now.',
77
+ turnstile: 'The anti-robot check did not go through. Try again.',
78
+ tooMany: 'Too many messages in a short time. Try again in a minute.',
79
+ fallbackTitle: 'Leave us your details',
80
+ fallbackCap: 'The assistant cannot answer any more today. Leave your details and we will get back to you.',
81
+ fallbackDown: 'The assistant cannot answer right now. Leave your details and we will get back to you.',
82
+ name: 'Your name',
83
+ contact: 'Phone or email',
84
+ need: 'Your request',
85
+ fallbackSend: 'Send the request',
86
+ fallbackSent: 'Thank you, your request was sent.',
87
+ fallbackMissing: 'Fill in all three fields.',
88
+ said: '{name}: {text}',
89
+ attachPhoto: 'Attach a photo',
90
+ photoHint: 'Avoid photos of people or of identity documents.',
91
+ photoAlt: 'Your photo',
92
+ photoSent: 'Photo sent',
93
+ photoLimit: 'Three photos at most per conversation.',
94
+ photoTooLarge: 'This photo is too large.',
95
+ photoUnreadable: 'This file is not a readable photo.',
96
+ photoFailed: 'The photo could not be sent. Try again.',
97
+ demoAsk: 'Can you tell me more about {topic}?',
98
+ demoQuestion: 'What are your lead times?',
99
+ demoAnswer: '(Demo) Here, the assistant answers from your site’s content.',
100
+ demoHint: 'Preview: the conversation is turned off.',
101
+ },
102
+ }
103
+
104
+ /** 'fr-CA' is French; anything but fr or en is French. */
105
+ export const langOf = (lang) => (String(lang ?? '').slice(0, 2).toLowerCase() === 'en' ? 'en' : 'fr')
106
+
107
+ export function stringsFor(lang, overrides = {}) {
108
+ const base = STRINGS[langOf(lang)]
109
+ const out = { ...base }
110
+ for (const [k, v] of Object.entries(overrides ?? {})) if (typeof v === 'string' && k in base) out[k] = v
111
+ return out
112
+ }
113
+
114
+ export const fill = (s, vars) => s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m))
@@ -0,0 +1,3 @@
1
+ import { startChat } from './trigger.js'
2
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => startChat())
3
+ else startChat()
package/src/trigger.js ADDED
@@ -0,0 +1,62 @@
1
+ // The inline part of the chat (dist/trigger.iife.js, under the manifest's inlineBudget). At page
2
+ // open it fetches nothing: it shows the bubble on every host and schedules the opener. The panel
3
+ // (dist/widget.js) is imported on the first touch of the bubble or the opener (@pmeweb/loader
4
+ // onInteraction: press, hover, focus, key) and opened on a click. Off the site's real hosts the
5
+ // panel runs its demo (manifest preview: test); the widget decides that, not this file.
6
+ //
7
+ // The opener shows once per visit (sessionStorage), on the first of: 20 s on the page, 30 % of the
8
+ // 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
+ import { onInteraction as interact } from '@pmeweb/loader'
11
+
12
+ export const OPENER_KEY = 'pmw-chat-opener'
13
+ export const OPENER_DELAY_MS = 20000
14
+ export const OPENER_SCROLL = 0.3
15
+
16
+ export function startChat({
17
+ doc = document, win = window, importLazy = (u) => import(/* @vite-ignore */ u), onInteraction = interact,
18
+ } = {}) {
19
+ const root = doc.getElementById('pmw-chat')
20
+ if (!root) return null
21
+ root.hidden = false
22
+ const teaser = root.querySelector('.pmw-chat__teaser')
23
+ let widget = null
24
+ const load = () => (widget ??= importLazy(root.dataset.pmwLazy).then((m) => m.mountChat(root), (e) => { widget = null; throw e }))
25
+ onInteraction(root, () => load().catch(() => {}))
26
+
27
+ const store = win.sessionStorage
28
+ let done = false
29
+ try { done = !!store?.getItem(OPENER_KEY) } catch { /* storage blocked: show once per page */ }
30
+ const listeners = []
31
+ const on = (t, ev, f) => { t.addEventListener(ev, f, { passive: true }); listeners.push([t, ev, f]) }
32
+ let timer
33
+ const finish = () => {
34
+ done = true
35
+ 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
+ }
39
+ const show = () => {
40
+ if (done || !teaser || root.hasAttribute('data-open')) return
41
+ finish()
42
+ teaser.hidden = false
43
+ }
44
+ root.addEventListener('click', (e) => {
45
+ const t = e.target.closest?.('[data-pmw-chat-open],[data-pmw-chat-dismiss]')
46
+ if (!t) return
47
+ if (teaser) teaser.hidden = true
48
+ finish()
49
+ if (t.hasAttribute('data-pmw-chat-open')) load().then((w) => w.open(), () => {})
50
+ })
51
+ if (!done && teaser) {
52
+ timer = win.setTimeout(show, OPENER_DELAY_MS)
53
+ on(win, 'scroll', () => {
54
+ const room = doc.documentElement.scrollHeight - win.innerHeight
55
+ if (room > 0 && win.scrollY / room >= OPENER_SCROLL) show()
56
+ })
57
+ if (win.matchMedia?.('(pointer: fine)').matches) {
58
+ on(doc, 'mouseout', (e) => { if (!e.relatedTarget && e.clientY <= 0) show() })
59
+ }
60
+ }
61
+ return { load, show }
62
+ }
package/src/widget.css ADDED
@@ -0,0 +1,108 @@
1
+ /* @pmeweb/plugin-chat: the panel, injected by the lazy widget. Like chat.css, the --pmw-chat-*
2
+ variables are not declared here: each use carries its AA default as a fallback
3
+ (a11y-tools/contrast.mjs): #1f2328 on #f1f3f6 14.21:1, #ffffff on #0a1b3d 16.98:1, #4b5563 on
4
+ #ffffff 7.56:1, #b42318 on #ffffff 6.57:1, #0a1b3d on #ffffff 16.98:1. Field borders #7d8590 on
5
+ #ffffff 3.73:1 (form borders need 3:1). The fields stay white with their own text colour
6
+ (--pmw-chat-field-fg), so a dark site's light ink never lands on them. Chat.astro sets the panel's
7
+ colours from the site's design.colors, each pair checked (src/colors.js panelTheme). */
8
+ .pmw-chat__panel {
9
+ position: fixed; z-index: 81; inset: 0;
10
+ display: flex; flex-direction: column;
11
+ background: var(--pmw-chat-panel-bg, #ffffff); color: var(--pmw-chat-fg, #1f2328);
12
+ font-family: var(--pmw-chat-font, inherit); font-size: 16px; line-height: 1.45;
13
+ }
14
+ .pmw-chat__panel[hidden], .pmw-chat__panel [hidden] { display: none !important; }
15
+ @media (min-width: 640px) {
16
+ .pmw-chat__panel {
17
+ inset: auto;
18
+ right: calc(env(safe-area-inset-right, 0px) + var(--pmw-chat-right, 16px));
19
+ bottom: calc(env(safe-area-inset-bottom, 0px) + var(--pmw-chat-bottom, 16px));
20
+ width: 380px; height: min(600px, calc(100dvh - 32px));
21
+ border: 1px solid var(--pmw-chat-border, #c9ced6); border-radius: 12px; overflow: hidden;
22
+ box-shadow: 0 12px 36px rgb(0 0 0 / 0.22);
23
+ }
24
+ }
25
+ .pmw-chat__panel button { font: inherit; cursor: pointer; }
26
+ .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
+
29
+ .pmw-chat__head {
30
+ display: flex; align-items: center; gap: 10px; padding: 10px 8px 10px 12px;
31
+ background: var(--pmw-chat-accent, #0a1b3d); color: var(--pmw-chat-accent-fg, #ffffff);
32
+ }
33
+ .pmw-chat__head .pmw-chat__avatar { width: 40px; height: 40px; box-shadow: none; background: var(--pmw-chat-avatar-bg, var(--pmw-chat-accent-fg, #ffffff)); color: var(--pmw-chat-avatar-fg, var(--pmw-chat-accent, #0a1b3d)); }
34
+ .pmw-chat__head .pmw-chat__avatar img { width: 40px; height: 40px; }
35
+ .pmw-chat__who { flex: 1; min-width: 0; }
36
+ .pmw-chat__name { margin: 0; font-weight: 600; font-size: 16px; }
37
+ .pmw-chat__role { margin: 0; font-size: 14px; }
38
+ .pmw-chat__dot { flex: none; width: 10px; height: 10px; border-radius: 50%; background: var(--pmw-chat-live, #3fb950); box-shadow: 0 0 0 2px var(--pmw-chat-accent, #0a1b3d); }
39
+ .pmw-chat__close {
40
+ flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%;
41
+ display: flex; align-items: center; justify-content: center; background: none; color: inherit;
42
+ }
43
+ .pmw-chat__close svg { width: 18px; height: 18px; }
44
+
45
+ .pmw-chat__body { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow-y: auto; padding: 10px 12px; }
46
+ .pmw-chat__notice { margin: 0 0 8px; font-size: 14px; color: var(--pmw-chat-muted, #4b5563); }
47
+ .pmw-chat__notice a { color: var(--pmw-chat-link, #0a1b3d); text-decoration: underline; text-underline-offset: 2px; }
48
+ .pmw-chat__log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
49
+ .pmw-chat__msg { max-width: 85%; padding: 8px 12px; border-radius: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
50
+ .pmw-chat__msg--bot, .pmw-chat__msg--owner { align-self: flex-start; background: var(--pmw-chat-other-bg, #f1f3f6); color: var(--pmw-chat-fg, #1f2328); border-bottom-left-radius: 4px; }
51
+ .pmw-chat__msg--visitor { align-self: flex-end; background: var(--pmw-chat-accent, #0a1b3d); color: var(--pmw-chat-accent-fg, #ffffff); border-bottom-right-radius: 4px; }
52
+ .pmw-chat__msg.is-pending { opacity: 0.75; }
53
+ .pmw-chat__msg.is-failed { outline: 2px solid var(--pmw-chat-error, #b42318); }
54
+ .pmw-chat__retry { display: block; margin-top: 4px; font-size: 14px; }
55
+ .pmw-chat__retry button { padding: 0; border: 0; background: none; color: inherit; text-decoration: underline; }
56
+ .pmw-chat__typing { align-self: flex-start; display: flex; gap: 4px; padding: 12px; border-radius: 12px; background: var(--pmw-chat-other-bg, #f1f3f6); }
57
+ .pmw-chat__typing span { width: 6px; height: 6px; border-radius: 50%; background: var(--pmw-chat-muted, #4b5563); animation: pmw-chat-dot 1.2s infinite ease-in-out; }
58
+ .pmw-chat__typing span:nth-child(2) { animation-delay: 0.15s; }
59
+ .pmw-chat__typing span:nth-child(3) { animation-delay: 0.3s; }
60
+ @keyframes pmw-chat-dot { 0%, 80%, 100% { opacity: 0.3; } 40% { opacity: 1; } }
61
+ @media (prefers-reduced-motion: reduce) {
62
+ .pmw-chat__typing span { animation: none; opacity: 0.7; }
63
+ }
64
+ .pmw-chat__state { margin: 8px 0 0; font-size: 14px; color: var(--pmw-chat-muted, #4b5563); }
65
+ .pmw-chat__state:empty { display: none; }
66
+ .pmw-chat__ended { margin-top: 12px; }
67
+ .pmw-chat__ended p { margin: 0 0 8px; }
68
+
69
+ .pmw-chat__composer { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; padding: 10px 12px; border-top: 1px solid var(--pmw-chat-border, #c9ced6); }
70
+ .pmw-chat__composer textarea { flex: 1; min-width: 0; resize: none; }
71
+ .pmw-chat__composer textarea, .pmw-chat__field input, .pmw-chat__field textarea {
72
+ font: inherit; font-size: 16px; padding: 8px 10px; border-radius: 8px;
73
+ border: 1px solid var(--pmw-chat-field-border, #7d8590); background: #ffffff; color: var(--pmw-chat-field-fg, #1f2328);
74
+ }
75
+ .pmw-chat__send {
76
+ min-height: 44px; padding: 8px 16px; border: 0; border-radius: 8px; font-weight: 600;
77
+ background: var(--pmw-chat-accent, #0a1b3d); color: var(--pmw-chat-accent-fg, #ffffff);
78
+ }
79
+ .pmw-chat__send:disabled, .pmw-chat__composer textarea:disabled { cursor: not-allowed; opacity: 0.6; }
80
+ .pmw-chat__restart { min-height: 44px; padding: 8px 16px; border-radius: 8px; border: 1px solid var(--pmw-chat-link, #0a1b3d); background: none; color: var(--pmw-chat-link, #0a1b3d); font-weight: 600; }
81
+ .pmw-chat__composer .pmw-turnstile, .pmw-chat__fallback .pmw-turnstile { flex-basis: 100%; }
82
+ /* The photo picker: a real file input, visually hidden inside its label, so Tab reaches it and the
83
+ label shows its focus ring. 44 x 44 like every other control. */
84
+ .pmw-chat__attach {
85
+ flex: none; position: relative; width: 44px; height: 44px; border-radius: 8px; cursor: pointer;
86
+ display: flex; align-items: center; justify-content: center;
87
+ border: 1px solid var(--pmw-chat-field-border, #7d8590); color: var(--pmw-chat-field-fg, #1f2328); background: #ffffff;
88
+ }
89
+ .pmw-chat__attach svg { width: 22px; height: 22px; }
90
+ .pmw-chat__attach:focus-within { outline: 3px solid var(--pmw-chat-focus, #0a1b3d); outline-offset: 2px; }
91
+ .pmw-chat__hint { flex-basis: 100%; margin: 0; font-size: 14px; color: var(--pmw-chat-muted, #4b5563); }
92
+ .pmw-chat__composer .pmw-chat__photo-error { flex-basis: 100%; }
93
+ .pmw-chat__photo { display: block; max-width: 100%; max-height: 220px; border-radius: 8px; }
94
+
95
+ .pmw-chat__fallback { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
96
+ .pmw-chat__fb-title { margin: 0; font-weight: 600; }
97
+ .pmw-chat__fb-intro { margin: 0; font-size: 15px; }
98
+ .pmw-chat__field { display: flex; flex-direction: column; gap: 4px; }
99
+ .pmw-chat__field label { font-size: 14px; font-weight: 600; }
100
+ .pmw-chat__error { margin: 0; font-size: 14px; color: var(--pmw-chat-error, #b42318); }
101
+ .pmw-chat__error:empty { display: none; }
102
+ .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; }
103
+ /* A reply arrived while the panel was closed. */
104
+ .pmw-chat[data-unread] .pmw-chat__bubble { position: relative; }
105
+ .pmw-chat[data-unread] .pmw-chat__bubble::after {
106
+ content: ''; position: absolute; top: 2px; left: 38px; width: 12px; height: 12px; border-radius: 50%;
107
+ background: var(--pmw-chat-error, #b42318); box-shadow: 0 0 0 2px #ffffff;
108
+ }