@morgan-and-morgan/intake-agent 0.1.0-alpha.1
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/README.md +17 -0
- package/dist/mm-intake-widget.js +587 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @morgan-and-morgan/intake-agent
|
|
2
|
+
|
|
3
|
+
Load the widget with a script tag, pinning an exact version:
|
|
4
|
+
|
|
5
|
+
```html
|
|
6
|
+
<script
|
|
7
|
+
src="https://cdn.jsdelivr.net/npm/@morgan-and-morgan/intake-agent@<version>/dist/mm-intake-widget.js"
|
|
8
|
+
data-chat-url="https://example.com/chat"
|
|
9
|
+
></script>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Attributes
|
|
13
|
+
|
|
14
|
+
| Attribute | Default | Purpose |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| `data-chat-url` | `/embed/intake` | Chat iframe target (resolved against the loader's origin) |
|
|
17
|
+
| `data-auto-open` | `false` | Open the chat immediately after mount |
|
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
"use strict";
|
|
3
|
+
const DEFAULT_CHAT_PATH = "/embed/intake";
|
|
4
|
+
const readLoaderConfig = (script, baseUri) => {
|
|
5
|
+
var _a;
|
|
6
|
+
const scriptUrl = new URL(script.src, baseUri);
|
|
7
|
+
const chatUrl = new URL((_a = script.dataset.chatUrl) != null ? _a : DEFAULT_CHAT_PATH, scriptUrl).href;
|
|
8
|
+
return {
|
|
9
|
+
autoOpen: script.dataset.autoOpen === "true",
|
|
10
|
+
chatOrigin: new URL(chatUrl).origin,
|
|
11
|
+
chatUrl
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
const OPEN_STATE_KEY = "mm-intake-widget-open";
|
|
15
|
+
const UNREAD_STATE_KEY = "mm-intake-widget-unread";
|
|
16
|
+
const readFlag = (key, storage) => {
|
|
17
|
+
try {
|
|
18
|
+
return storage.getItem(key) === "true";
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const writeFlag = (key, value, storage) => {
|
|
24
|
+
try {
|
|
25
|
+
storage.setItem(key, String(value));
|
|
26
|
+
} catch {
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const readOpenState = (storage = sessionStorage) => readFlag(OPEN_STATE_KEY, storage);
|
|
30
|
+
const writeOpenState = (isOpen, storage = sessionStorage) => writeFlag(OPEN_STATE_KEY, isOpen, storage);
|
|
31
|
+
const readUnreadState = (storage = sessionStorage) => readFlag(UNREAD_STATE_KEY, storage);
|
|
32
|
+
const writeUnreadState = (hasUnread, storage = sessionStorage) => {
|
|
33
|
+
if (hasUnread) {
|
|
34
|
+
writeFlag(UNREAD_STATE_KEY, true, storage);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
storage.removeItem(UNREAD_STATE_KEY);
|
|
39
|
+
} catch {
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const EMBED_PROTOCOL_VERSION = 1;
|
|
43
|
+
const EMBED_MESSAGE_TYPE = Object.freeze({
|
|
44
|
+
HOST_READY: "mm:intake:host-ready",
|
|
45
|
+
CHILD_READY: "mm:intake:child-ready",
|
|
46
|
+
SIGNING_REQUESTED: "mm:intake:signing-requested",
|
|
47
|
+
SIGNING_RECORDED: "mm:intake:signing-recorded",
|
|
48
|
+
// Chat asks the host page for a signed-in token; the host answers with
|
|
49
|
+
// `{ token?: string }`. A script-tag mount has no getAuthToken, so it always
|
|
50
|
+
// answers tokenless and the chat stays anonymous.
|
|
51
|
+
AUTH_TOKEN_REQUEST: "mm:intake:auth-token-request",
|
|
52
|
+
AUTH_TOKEN: "mm:intake:auth-token",
|
|
53
|
+
// Host tells the chat whether an on-screen keyboard is up, as
|
|
54
|
+
// `{ isOpen: boolean }`. The chat cannot measure this itself once framed: the
|
|
55
|
+
// host already shrinks the iframe to the visible area, so inside it the layout
|
|
56
|
+
// and visual viewports agree and the keyboard is invisible.
|
|
57
|
+
KEYBOARD_STATE: "mm:intake:keyboard-state",
|
|
58
|
+
// Chat tells the host whether its composer holds the caret, as
|
|
59
|
+
// `{ isFocused: boolean }`. The answer back, on the way out: the keyboard
|
|
60
|
+
// belongs to the focused field, so losing it means the keyboard is already
|
|
61
|
+
// leaving — which the host's own visual viewport only admits ~500ms later.
|
|
62
|
+
CARET_STATE: "mm:intake:caret-state",
|
|
63
|
+
// Chat tells the host an inbound AI or operator message just landed, so the
|
|
64
|
+
// launcher's unread dot can mean something. Payload-free: the dot is a dot,
|
|
65
|
+
// and only the chat knows a message arrived while only the host knows whether
|
|
66
|
+
// the panel was closed at the time.
|
|
67
|
+
MESSAGE_RECEIVED: "mm:intake:message-received"
|
|
68
|
+
});
|
|
69
|
+
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
70
|
+
const isEmbedProtocolMessage = (value, type) => isRecord(value) && value.type === type && value.version === EMBED_PROTOCOL_VERSION && isRecord(value.payload);
|
|
71
|
+
const createEmbedProtocolMessage = (type, payload = {}) => ({
|
|
72
|
+
type,
|
|
73
|
+
version: EMBED_PROTOCOL_VERSION,
|
|
74
|
+
payload
|
|
75
|
+
});
|
|
76
|
+
const SIGNING_COMPLETE_EVENT = "signing_complete";
|
|
77
|
+
const isLocalHostname = (hostname) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
78
|
+
const isAllowedSigningProtocol = (url) => url.protocol === "https:" || url.protocol === "http:" && isLocalHostname(url.hostname);
|
|
79
|
+
const buildSigningReturnUrl = (currentUrl = globalThis.location.href) => {
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(currentUrl);
|
|
82
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
url.searchParams.delete("event");
|
|
86
|
+
url.searchParams.delete("envelope_id");
|
|
87
|
+
return url.href;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const parseSigningRequest = (payload) => {
|
|
93
|
+
if (typeof payload.envelopeId !== "string" || payload.envelopeId.trim() === "") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (typeof payload.signingUrl !== "string") {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const url = new URL(payload.signingUrl);
|
|
101
|
+
if (!isAllowedSigningProtocol(url)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
envelopeId: payload.envelopeId,
|
|
106
|
+
signingUrl: url.href
|
|
107
|
+
};
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const readCompletedSigningEnvelopeId = (currentUrl = globalThis.location.href) => {
|
|
113
|
+
var _a;
|
|
114
|
+
try {
|
|
115
|
+
const params = new URL(currentUrl).searchParams;
|
|
116
|
+
const envelopeId = (_a = params.get("envelope_id")) == null ? void 0 : _a.trim();
|
|
117
|
+
return params.get("event") === SIGNING_COMPLETE_EVENT && envelopeId !== void 0 && envelopeId !== "" ? envelopeId : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const LUCIDE_ICON_PATHS = {
|
|
123
|
+
"message-circle": '<path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/>',
|
|
124
|
+
"x": '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
125
|
+
"scale": '<path d="M12 3v18"/><path d="m19 8 3 8a5 5 0 0 1-6 0zV7"/><path d="M3 7h1a17 17 0 0 0 8-2 17 17 0 0 0 8 2h1"/><path d="m5 8 3 8a5 5 0 0 1-6 0zV7"/><path d="M7 21h10"/>',
|
|
126
|
+
"shield-check": '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
|
|
127
|
+
"lock": '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
|
128
|
+
"sparkles": '<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>',
|
|
129
|
+
"send-horizontal": '<path d="M3.714 3.048a.498.498 0 0 0-.683.627l2.843 7.627a2 2 0 0 1 0 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 0 0 0-.904z"/><path d="M6 12h16"/>',
|
|
130
|
+
"file-check": '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m9 15 2 2 4-4"/>',
|
|
131
|
+
"circle-check": '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
|
|
132
|
+
"chevron-down": '<path d="m6 9 6 6 6-6"/>'
|
|
133
|
+
};
|
|
134
|
+
const FULL_PAGE_PANEL_QUERY = "(max-width:600px),(max-height:500px)";
|
|
135
|
+
const lucideIcon = (name, className, strokeWidth = 2) => `<svg class="${className}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${String(strokeWidth)}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${LUCIDE_ICON_PATHS[name]}</svg>`;
|
|
136
|
+
const CHAT_ICON = lucideIcon("message-circle", "mm-icon mm-icon-chat");
|
|
137
|
+
const OPEN_CLOSE_ICON = lucideIcon("x", "mm-icon mm-icon-close");
|
|
138
|
+
const WIDGET_STYLES = [
|
|
139
|
+
':host{all:initial;color-scheme:light;--mm-yellow:#fdeb01;--mm-yellow-hover:#f5de00;--mm-yellow-pressed:#e6cc00;--mm-blue:#115099;--mm-blue-hover:#0b3568;--mm-ink:#111;--mm-white:#fff;--mm-surface-blue:#f3f8ff;--mm-chrome:#e8eef2;--mm-line:#e2e8f0;--mm-muted:#545454;--mm-green:#0f9d58;--mm-font:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}',
|
|
140
|
+
"*,*::before,*::after{box-sizing:border-box}",
|
|
141
|
+
"button{font:inherit}",
|
|
142
|
+
"@keyframes mm-launcher-in{from{opacity:0;transform:translateY(10px) scale(.85)}to{opacity:1;transform:none}}",
|
|
143
|
+
"@keyframes mm-panel-in{from{opacity:0;transform:translateY(10px) scale(.98)}to{opacity:1;transform:none}}",
|
|
144
|
+
"@keyframes mm-panel-out{from{opacity:1;transform:none}to{opacity:0;transform:translateY(10px) scale(.98)}}",
|
|
145
|
+
"@keyframes mm-ping{0%{transform:scale(1);opacity:.55}70%,100%{transform:scale(2.4);opacity:0}}",
|
|
146
|
+
// Same register as the chat's own typing dots (typing-indicator.tsx): rest at
|
|
147
|
+
// .4 opacity, pulse to full with a 1.2x scale, each dot a beat behind the last.
|
|
148
|
+
"@keyframes mm-dot{0%,100%{opacity:.4;transform:scale(1)}50%{opacity:1;transform:scale(1.2)}}",
|
|
149
|
+
// The launcher stacks above the chat panel: it is the only close affordance
|
|
150
|
+
// (the panel is chrome-free), so the full-screen mobile panel must not cover it.
|
|
151
|
+
".mm-launcher{position:fixed;right:24px;bottom:24px;z-index:2147483001;display:grid;width:64px;height:64px;place-items:center;border:2px solid var(--mm-white);border-radius:999px;outline:none;background:var(--mm-yellow);color:var(--mm-ink);box-shadow:0 10px 28px rgba(17,17,17,.24);cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-user-drag:none;animation:mm-launcher-in .25s ease-out;transition:background-color .15s ease,transform .15s ease,box-shadow .15s ease}",
|
|
152
|
+
".mm-launcher *{user-select:none;-webkit-user-select:none;-webkit-user-drag:none}",
|
|
153
|
+
".mm-launcher:hover{background:var(--mm-yellow-hover);box-shadow:0 12px 32px rgba(17,17,17,.28);transform:translateY(-1px)}",
|
|
154
|
+
".mm-launcher:active{background:var(--mm-yellow-pressed);transform:translateY(0)}",
|
|
155
|
+
".mm-launcher:focus-visible{outline:3px solid var(--mm-blue);outline-offset:3px}",
|
|
156
|
+
".mm-badge{position:absolute;top:-2px;right:-2px;width:16px;height:16px;border:2px solid var(--mm-white);border-radius:999px;background:var(--mm-blue)}",
|
|
157
|
+
'.mm-badge::after{content:"";position:absolute;inset:-2px;border-radius:999px;background:var(--mm-blue);animation:mm-ping 2.4s ease-out infinite}',
|
|
158
|
+
".mm-icon{position:absolute;top:50%;left:50%;display:block;width:28px;height:28px;pointer-events:none;transform:translate(-50%,-50%)}",
|
|
159
|
+
".mm-icon-close{display:none}",
|
|
160
|
+
'.mm-launcher[aria-expanded="true"] .mm-icon-chat{display:none}',
|
|
161
|
+
'.mm-launcher[aria-expanded="true"] .mm-icon-close{display:block}',
|
|
162
|
+
// The panel wears the chat's header/trust-line tint, not its conversation
|
|
163
|
+
// tint. Measured on iOS 26: while the panel is full-page, Safari takes the
|
|
164
|
+
// colour of its own status-bar and URL-bar bands straight off this background
|
|
165
|
+
// — ahead of the host page's `theme-color` and root background, which it
|
|
166
|
+
// ignores outright once the chat frame has committed a navigation. So this is
|
|
167
|
+
// the lever that decides what the bands look like, and the bands sit right
|
|
168
|
+
// against the chat's header and trust line.
|
|
169
|
+
// Two flat halves rather than one colour, because the two bands are sampled
|
|
170
|
+
// independently: the top one always sits against the chat header, while the
|
|
171
|
+
// bottom one sits against the trust line or, with a keyboard up, the composer.
|
|
172
|
+
".mm-chat{position:fixed;right:24px;bottom:100px;z-index:2147483000;display:flex;flex-direction:column;width:min(400px,calc(100vw - 32px));height:min(640px,calc(100vh - 132px));overflow:hidden;border:1px solid var(--mm-line);border-radius:20px;background:linear-gradient(to bottom,var(--mm-chrome) 0 50%,var(--mm-panel-bottom,var(--mm-chrome)) 50% 100%);box-shadow:0 5px 25px rgba(0,0,0,.2);animation:mm-panel-in .18s ease-out both;font-family:var(--mm-font)}",
|
|
173
|
+
// Keyboard up, the chat drops its trust line and the composer becomes the
|
|
174
|
+
// bottom-most surface, so the bottom band has to follow the composer. Only
|
|
175
|
+
// the bottom: the header is still up there against the top one.
|
|
176
|
+
'.mm-chat[data-keyboard="open"]{--mm-panel-bottom:var(--mm-surface-blue)}',
|
|
177
|
+
'.mm-chat[data-state="closing"]{pointer-events:none;animation-name:mm-panel-out}',
|
|
178
|
+
'.mm-chat[data-state="closed"]{visibility:hidden;animation-name:none}',
|
|
179
|
+
".mm-frame-wrap{position:relative;min-height:0;flex:1;background:linear-gradient(to bottom,var(--mm-chrome) 0 50%,var(--mm-panel-bottom,var(--mm-chrome)) 50% 100%)}",
|
|
180
|
+
// A chat tone, never white: the frame paints this in the gap between the
|
|
181
|
+
// document's load event and the app's first frame, and on a phone that gap is
|
|
182
|
+
// the whole screen.
|
|
183
|
+
".mm-frame{display:block;width:100%;height:100%;border:0;background:var(--mm-chrome)}",
|
|
184
|
+
".mm-loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:5px;background:var(--mm-chrome)}",
|
|
185
|
+
".mm-dot{width:8px;height:8px;border-radius:999px;background:var(--mm-muted);animation:mm-dot 1.2s ease-in-out infinite}",
|
|
186
|
+
".mm-dot:nth-child(2){animation-delay:.15s}",
|
|
187
|
+
".mm-dot:nth-child(3){animation-delay:.3s}",
|
|
188
|
+
"[hidden]{display:none!important}",
|
|
189
|
+
// The close affordance rides the same tracked viewport as the panel: iOS
|
|
190
|
+
// Safari pans the page when the keyboard opens, and a launcher pinned to the
|
|
191
|
+
// layout viewport's top would be panned clean off the screen with it.
|
|
192
|
+
//
|
|
193
|
+
// `min-height:0` on the panel so a short landscape viewport shrinks it all the
|
|
194
|
+
// way down instead of overflowing the screen and clipping the chat's own
|
|
195
|
+
// rotate-your-phone overlay.
|
|
196
|
+
//
|
|
197
|
+
// 16px from the corner is right for a normal host site, which has nothing of
|
|
198
|
+
// its own down there. A host that mounts its own bottom chrome — the client
|
|
199
|
+
// portal and its tab bar — sets `--mm-launcher-bottom` on any ancestor of the
|
|
200
|
+
// mount (custom properties reach into the shadow tree) to lift the launcher
|
|
201
|
+
// clear of it. Only the closed launcher: open, it moves to the top corner and
|
|
202
|
+
// tracks the viewport instead.
|
|
203
|
+
`@media ${FULL_PAGE_PANEL_QUERY}{.mm-launcher{right:16px;bottom:var(--mm-launcher-bottom,16px)}.mm-launcher[aria-expanded="true"]{top:calc(var(--mm-viewport-top,0px) + 10px);right:10px;bottom:auto;width:44px;height:44px;box-shadow:0 4px 14px rgba(17,17,17,.2)}.mm-launcher[aria-expanded="true"] .mm-icon{width:22px;height:22px}.mm-chat{right:0;top:var(--mm-viewport-top,auto);bottom:0;width:100vw;height:var(--mm-viewport-height,100dvh);min-height:0;border:0;border-radius:0}}`,
|
|
204
|
+
"@media(prefers-reduced-motion:reduce){.mm-launcher,.mm-chat{animation:none;transition:none}.mm-badge::after,.mm-dot{animation:none}}"
|
|
205
|
+
].join("");
|
|
206
|
+
const WIDGET_MARKUP = [
|
|
207
|
+
`<style>${WIDGET_STYLES}</style>`,
|
|
208
|
+
`<button class="mm-launcher" type="button" aria-label="Open intake chat" aria-expanded="false" draggable="false">${CHAT_ICON}${OPEN_CLOSE_ICON}<span class="mm-badge" aria-hidden="true" hidden></span></button>`,
|
|
209
|
+
'<section class="mm-chat" role="dialog" aria-label="Morgan and Morgan intake chat" inert hidden>',
|
|
210
|
+
// Dots rather than a sentence: the panel is up for a second or two and a line
|
|
211
|
+
// of copy reads like an error the visitor has to act on. The label carries the
|
|
212
|
+
// same information for a screen reader.
|
|
213
|
+
'<div class="mm-frame-wrap"><div class="mm-loading" role="status" aria-label="Loading secure chat"><span class="mm-dot"></span><span class="mm-dot"></span><span class="mm-dot"></span></div><iframe class="mm-frame mm-chat-frame" title="Morgan and Morgan intake chat" referrerpolicy="strict-origin-when-cross-origin"></iframe></div>',
|
|
214
|
+
"</section>"
|
|
215
|
+
].join("");
|
|
216
|
+
const requireElement = (shadow, selector) => {
|
|
217
|
+
const element = shadow.querySelector(selector);
|
|
218
|
+
if (element === null) {
|
|
219
|
+
throw new Error(`Intake widget shell is missing "${selector}"`);
|
|
220
|
+
}
|
|
221
|
+
return element;
|
|
222
|
+
};
|
|
223
|
+
const renderWidgetShell = (shadow) => {
|
|
224
|
+
shadow.innerHTML = WIDGET_MARKUP;
|
|
225
|
+
return {
|
|
226
|
+
chatDialog: requireElement(shadow, ".mm-chat"),
|
|
227
|
+
chatFrame: requireElement(shadow, ".mm-chat-frame"),
|
|
228
|
+
chatLoading: requireElement(shadow, ".mm-loading"),
|
|
229
|
+
launcher: requireElement(shadow, ".mm-launcher"),
|
|
230
|
+
unreadBadge: requireElement(shadow, ".mm-badge")
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
const PANEL_ANIMATION_MS = 180;
|
|
234
|
+
const CHAT_CHROME_COLOR = "#e8eef2";
|
|
235
|
+
const KEYBOARD_MIN_SHRINK_PX = 100;
|
|
236
|
+
const CHAT_READY_FALLBACK_MS = 4e3;
|
|
237
|
+
const getLauncherLabel = (isOpen, hasUnread) => {
|
|
238
|
+
if (isOpen) {
|
|
239
|
+
return "Close intake chat";
|
|
240
|
+
}
|
|
241
|
+
return hasUnread ? "Open intake chat, new message" : "Open intake chat";
|
|
242
|
+
};
|
|
243
|
+
const cleanSigningReturnUrl = () => {
|
|
244
|
+
const cleanedReturnUrl = buildSigningReturnUrl();
|
|
245
|
+
if (cleanedReturnUrl !== null) {
|
|
246
|
+
globalThis.history.replaceState(globalThis.history.state, "", cleanedReturnUrl);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const mountIntakeWidget = (config, onDestroy) => {
|
|
250
|
+
var _a;
|
|
251
|
+
const mount = document.createElement("div");
|
|
252
|
+
mount.dataset.mmIntakeWidget = "";
|
|
253
|
+
const shadow = mount.attachShadow({ mode: "open" });
|
|
254
|
+
const elements = renderWidgetShell(shadow);
|
|
255
|
+
document.body.append(mount);
|
|
256
|
+
const completedSigningEnvelopeId = readCompletedSigningEnvelopeId();
|
|
257
|
+
let chatHasLoaded = false;
|
|
258
|
+
let chatCloseTimer;
|
|
259
|
+
let chatReadyTimer;
|
|
260
|
+
let isKeyboardOpen = false;
|
|
261
|
+
let hasReleasedKeyboardEarly = false;
|
|
262
|
+
let hasUnread = readUnreadState();
|
|
263
|
+
const setHasUnread = (next) => {
|
|
264
|
+
hasUnread = next;
|
|
265
|
+
elements.unreadBadge.hidden = !next;
|
|
266
|
+
writeUnreadState(next);
|
|
267
|
+
syncLauncherLabel();
|
|
268
|
+
};
|
|
269
|
+
const syncLauncherLabel = () => {
|
|
270
|
+
const isOpen = elements.chatDialog.dataset.state === "open";
|
|
271
|
+
elements.launcher.setAttribute("aria-expanded", String(isOpen));
|
|
272
|
+
elements.launcher.setAttribute("aria-label", getLauncherLabel(isOpen, hasUnread));
|
|
273
|
+
};
|
|
274
|
+
const postToChat = (message) => {
|
|
275
|
+
var _a2;
|
|
276
|
+
(_a2 = elements.chatFrame.contentWindow) == null ? void 0 : _a2.postMessage(message, config.chatOrigin);
|
|
277
|
+
};
|
|
278
|
+
const postHostReady = () => {
|
|
279
|
+
const currentUrl = globalThis.location.href;
|
|
280
|
+
const completedEnvelopeId = readCompletedSigningEnvelopeId(currentUrl);
|
|
281
|
+
const hostUrl = buildSigningReturnUrl(currentUrl);
|
|
282
|
+
postToChat(
|
|
283
|
+
createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.HOST_READY, {
|
|
284
|
+
...hostUrl === null ? {} : { hostUrl },
|
|
285
|
+
...completedEnvelopeId === null ? {} : { completedEnvelopeId }
|
|
286
|
+
})
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
const answerAuthTokenRequest = async () => {
|
|
290
|
+
var _a2;
|
|
291
|
+
let token;
|
|
292
|
+
try {
|
|
293
|
+
token = await ((_a2 = config.getAuthToken) == null ? void 0 : _a2.call(config));
|
|
294
|
+
} catch {
|
|
295
|
+
token = void 0;
|
|
296
|
+
}
|
|
297
|
+
postToChat(createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.AUTH_TOKEN, token === void 0 ? {} : { token }));
|
|
298
|
+
};
|
|
299
|
+
const ensureChatLoaded = () => {
|
|
300
|
+
if (elements.chatFrame.getAttribute("src") !== null) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
elements.chatLoading.hidden = false;
|
|
304
|
+
elements.chatFrame.src = config.chatUrl;
|
|
305
|
+
};
|
|
306
|
+
const revealChat = () => {
|
|
307
|
+
globalThis.clearTimeout(chatReadyTimer);
|
|
308
|
+
chatReadyTimer = void 0;
|
|
309
|
+
elements.chatLoading.hidden = true;
|
|
310
|
+
};
|
|
311
|
+
const syncViewport = () => {
|
|
312
|
+
const viewport = globalThis.visualViewport;
|
|
313
|
+
if (!viewport || viewport.height === 0) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const keyboardShrink = document.documentElement.clientHeight - viewport.height;
|
|
317
|
+
if (hasReleasedKeyboardEarly) {
|
|
318
|
+
if (keyboardShrink > KEYBOARD_MIN_SHRINK_PX) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
hasReleasedKeyboardEarly = false;
|
|
322
|
+
}
|
|
323
|
+
mount.style.setProperty("--mm-viewport-height", `${viewport.height}px`);
|
|
324
|
+
mount.style.setProperty("--mm-viewport-top", `${viewport.offsetTop}px`);
|
|
325
|
+
setKeyboardOpen(keyboardShrink > KEYBOARD_MIN_SHRINK_PX);
|
|
326
|
+
};
|
|
327
|
+
const releaseKeyboardEarly = () => {
|
|
328
|
+
if (!isKeyboardOpen) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
mount.style.setProperty("--mm-viewport-height", `${document.documentElement.clientHeight}px`);
|
|
332
|
+
mount.style.setProperty("--mm-viewport-top", "0px");
|
|
333
|
+
hasReleasedKeyboardEarly = true;
|
|
334
|
+
setKeyboardOpen(false);
|
|
335
|
+
};
|
|
336
|
+
const fullPagePanelQuery = (_a = globalThis.matchMedia) == null ? void 0 : _a.call(globalThis, FULL_PAGE_PANEL_QUERY);
|
|
337
|
+
let themeColorBackup = null;
|
|
338
|
+
let createdThemeColor = null;
|
|
339
|
+
const hostRoots = [document.documentElement, document.body];
|
|
340
|
+
let hostBackgroundColorBackup = null;
|
|
341
|
+
const borrowHostChrome = () => {
|
|
342
|
+
if (themeColorBackup === null) {
|
|
343
|
+
hostBackgroundColorBackup = hostRoots.map((root) => root.style.backgroundColor);
|
|
344
|
+
const metas = [...document.head.querySelectorAll('meta[name="theme-color"]')];
|
|
345
|
+
if (metas.length === 0) {
|
|
346
|
+
createdThemeColor = document.createElement("meta");
|
|
347
|
+
createdThemeColor.name = "theme-color";
|
|
348
|
+
document.head.append(createdThemeColor);
|
|
349
|
+
metas.push(createdThemeColor);
|
|
350
|
+
}
|
|
351
|
+
themeColorBackup = metas.map((meta) => ({ content: meta.content, meta }));
|
|
352
|
+
}
|
|
353
|
+
for (const root of hostRoots) {
|
|
354
|
+
root.style.setProperty("background-color", CHAT_CHROME_COLOR, "important");
|
|
355
|
+
}
|
|
356
|
+
for (const { meta } of themeColorBackup) {
|
|
357
|
+
meta.content = CHAT_CHROME_COLOR;
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
const restoreHostChrome = () => {
|
|
361
|
+
var _a2;
|
|
362
|
+
if (themeColorBackup === null) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
for (const { content, meta } of themeColorBackup) {
|
|
366
|
+
meta.content = content;
|
|
367
|
+
}
|
|
368
|
+
createdThemeColor == null ? void 0 : createdThemeColor.remove();
|
|
369
|
+
createdThemeColor = null;
|
|
370
|
+
themeColorBackup = null;
|
|
371
|
+
for (const [index, root] of hostRoots.entries()) {
|
|
372
|
+
root.style.backgroundColor = (_a2 = hostBackgroundColorBackup == null ? void 0 : hostBackgroundColorBackup[index]) != null ? _a2 : "";
|
|
373
|
+
}
|
|
374
|
+
hostBackgroundColorBackup = null;
|
|
375
|
+
};
|
|
376
|
+
const syncHostChrome = () => {
|
|
377
|
+
if (elements.chatDialog.dataset.state !== "open") {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (fullPagePanelQuery == null ? void 0 : fullPagePanelQuery.matches) {
|
|
381
|
+
borrowHostChrome();
|
|
382
|
+
} else {
|
|
383
|
+
restoreHostChrome();
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
function setKeyboardOpen(next) {
|
|
387
|
+
if (next === isKeyboardOpen) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
isKeyboardOpen = next;
|
|
391
|
+
elements.chatDialog.dataset.keyboard = next ? "open" : "closed";
|
|
392
|
+
postToChat(createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.KEYBOARD_STATE, { isOpen: next }));
|
|
393
|
+
syncHostChrome();
|
|
394
|
+
}
|
|
395
|
+
const startViewportTracking = () => {
|
|
396
|
+
var _a2, _b;
|
|
397
|
+
(_a2 = globalThis.visualViewport) == null ? void 0 : _a2.addEventListener("resize", syncViewport);
|
|
398
|
+
(_b = globalThis.visualViewport) == null ? void 0 : _b.addEventListener("scroll", syncViewport);
|
|
399
|
+
fullPagePanelQuery == null ? void 0 : fullPagePanelQuery.addEventListener("change", syncHostChrome);
|
|
400
|
+
syncViewport();
|
|
401
|
+
syncHostChrome();
|
|
402
|
+
};
|
|
403
|
+
const stopViewportTracking = () => {
|
|
404
|
+
var _a2, _b;
|
|
405
|
+
(_a2 = globalThis.visualViewport) == null ? void 0 : _a2.removeEventListener("resize", syncViewport);
|
|
406
|
+
(_b = globalThis.visualViewport) == null ? void 0 : _b.removeEventListener("scroll", syncViewport);
|
|
407
|
+
fullPagePanelQuery == null ? void 0 : fullPagePanelQuery.removeEventListener("change", syncHostChrome);
|
|
408
|
+
setKeyboardOpen(false);
|
|
409
|
+
hasReleasedKeyboardEarly = false;
|
|
410
|
+
restoreHostChrome();
|
|
411
|
+
mount.style.removeProperty("--mm-viewport-height");
|
|
412
|
+
mount.style.removeProperty("--mm-viewport-top");
|
|
413
|
+
};
|
|
414
|
+
const finishChatClose = () => {
|
|
415
|
+
if (elements.chatDialog.dataset.state !== "closing") {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
globalThis.clearTimeout(chatCloseTimer);
|
|
419
|
+
chatCloseTimer = void 0;
|
|
420
|
+
elements.chatDialog.dataset.state = "closed";
|
|
421
|
+
stopViewportTracking();
|
|
422
|
+
};
|
|
423
|
+
const openChat = () => {
|
|
424
|
+
globalThis.clearTimeout(chatCloseTimer);
|
|
425
|
+
chatCloseTimer = void 0;
|
|
426
|
+
elements.chatDialog.hidden = false;
|
|
427
|
+
elements.chatDialog.dataset.state = "open";
|
|
428
|
+
elements.chatDialog.removeAttribute("aria-hidden");
|
|
429
|
+
elements.chatDialog.removeAttribute("inert");
|
|
430
|
+
setHasUnread(false);
|
|
431
|
+
startViewportTracking();
|
|
432
|
+
ensureChatLoaded();
|
|
433
|
+
elements.chatFrame.focus();
|
|
434
|
+
writeOpenState(true);
|
|
435
|
+
if (chatHasLoaded) {
|
|
436
|
+
postHostReady();
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
const closeChat = () => {
|
|
440
|
+
var _a2;
|
|
441
|
+
elements.launcher.focus();
|
|
442
|
+
writeOpenState(false);
|
|
443
|
+
const wasOpen = elements.chatDialog.dataset.state === "open";
|
|
444
|
+
if (wasOpen) {
|
|
445
|
+
elements.chatDialog.dataset.state = "closing";
|
|
446
|
+
elements.chatDialog.setAttribute("aria-hidden", "true");
|
|
447
|
+
elements.chatDialog.setAttribute("inert", "");
|
|
448
|
+
}
|
|
449
|
+
syncLauncherLabel();
|
|
450
|
+
if (!wasOpen) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if ((_a2 = globalThis.matchMedia) == null ? void 0 : _a2.call(globalThis, "(prefers-reduced-motion: reduce)").matches) {
|
|
454
|
+
finishChatClose();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
chatCloseTimer = globalThis.setTimeout(finishChatClose, PANEL_ANIMATION_MS + 50);
|
|
458
|
+
};
|
|
459
|
+
const openSigning = (payload) => {
|
|
460
|
+
const request = parseSigningRequest(payload);
|
|
461
|
+
if (request === null) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
globalThis.location.assign(request.signingUrl);
|
|
465
|
+
};
|
|
466
|
+
const handleMessage = (event) => {
|
|
467
|
+
const message = event.data;
|
|
468
|
+
if (event.source === elements.chatFrame.contentWindow && event.origin === config.chatOrigin) {
|
|
469
|
+
if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.CHILD_READY)) {
|
|
470
|
+
revealChat();
|
|
471
|
+
postHostReady();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.AUTH_TOKEN_REQUEST)) {
|
|
475
|
+
void answerAuthTokenRequest();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.CARET_STATE)) {
|
|
479
|
+
if (message.payload.isFocused === true) {
|
|
480
|
+
hasReleasedKeyboardEarly = false;
|
|
481
|
+
} else {
|
|
482
|
+
releaseKeyboardEarly();
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.MESSAGE_RECEIVED)) {
|
|
487
|
+
if (elements.chatDialog.dataset.state !== "open") {
|
|
488
|
+
setHasUnread(true);
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.SIGNING_REQUESTED)) {
|
|
493
|
+
openSigning(message.payload);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.SIGNING_RECORDED)) {
|
|
497
|
+
cleanSigningReturnUrl();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const handleKeydown = (event) => {
|
|
502
|
+
if (event.key !== "Escape") {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (elements.chatDialog.dataset.state === "open") {
|
|
506
|
+
closeChat();
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
const destroy = () => {
|
|
510
|
+
stopViewportTracking();
|
|
511
|
+
globalThis.clearTimeout(chatCloseTimer);
|
|
512
|
+
globalThis.clearTimeout(chatReadyTimer);
|
|
513
|
+
globalThis.removeEventListener("message", handleMessage);
|
|
514
|
+
document.removeEventListener("keydown", handleKeydown);
|
|
515
|
+
mount.remove();
|
|
516
|
+
onDestroy == null ? void 0 : onDestroy();
|
|
517
|
+
};
|
|
518
|
+
elements.launcher.addEventListener("click", () => {
|
|
519
|
+
if (elements.chatDialog.dataset.state === "open") {
|
|
520
|
+
closeChat();
|
|
521
|
+
} else {
|
|
522
|
+
openChat();
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
elements.chatDialog.addEventListener("animationend", (event) => {
|
|
526
|
+
if (event.target === elements.chatDialog) {
|
|
527
|
+
finishChatClose();
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
elements.chatFrame.addEventListener("load", () => {
|
|
531
|
+
chatHasLoaded = true;
|
|
532
|
+
syncHostChrome();
|
|
533
|
+
chatReadyTimer != null ? chatReadyTimer : chatReadyTimer = globalThis.setTimeout(revealChat, CHAT_READY_FALLBACK_MS);
|
|
534
|
+
postHostReady();
|
|
535
|
+
});
|
|
536
|
+
globalThis.addEventListener("message", handleMessage);
|
|
537
|
+
document.addEventListener("keydown", handleKeydown);
|
|
538
|
+
setHasUnread(hasUnread);
|
|
539
|
+
if (completedSigningEnvelopeId !== null || readOpenState()) {
|
|
540
|
+
openChat();
|
|
541
|
+
}
|
|
542
|
+
return Object.freeze({
|
|
543
|
+
close: closeChat,
|
|
544
|
+
destroy,
|
|
545
|
+
open: openChat,
|
|
546
|
+
version: EMBED_PROTOCOL_VERSION
|
|
547
|
+
});
|
|
548
|
+
};
|
|
549
|
+
const clearWidgetGlobals = () => {
|
|
550
|
+
try {
|
|
551
|
+
delete globalThis.MMIntakeWidget;
|
|
552
|
+
delete globalThis.__MMIntakeWidgetBootstrapping;
|
|
553
|
+
} catch {
|
|
554
|
+
globalThis.MMIntakeWidget = void 0;
|
|
555
|
+
globalThis.__MMIntakeWidgetBootstrapping = void 0;
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
const documentReady = async () => {
|
|
559
|
+
if (document.readyState !== "loading") {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
await new Promise((resolve) => {
|
|
563
|
+
document.addEventListener(
|
|
564
|
+
"DOMContentLoaded",
|
|
565
|
+
() => {
|
|
566
|
+
resolve();
|
|
567
|
+
},
|
|
568
|
+
{ once: true }
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
};
|
|
572
|
+
const bootstrap = async () => {
|
|
573
|
+
const script = document.currentScript;
|
|
574
|
+
if (!(script instanceof HTMLScriptElement) || globalThis.MMIntakeWidget !== void 0 || globalThis.__MMIntakeWidgetBootstrapping === true) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
globalThis.__MMIntakeWidgetBootstrapping = true;
|
|
578
|
+
const config = readLoaderConfig(script, document.baseURI);
|
|
579
|
+
await documentReady();
|
|
580
|
+
const api = mountIntakeWidget(config, clearWidgetGlobals);
|
|
581
|
+
globalThis.MMIntakeWidget = api;
|
|
582
|
+
if (config.autoOpen) {
|
|
583
|
+
api.open();
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
void bootstrap();
|
|
587
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@morgan-and-morgan/intake-agent",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Embeddable chat widget loader.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"README.md"
|
|
9
|
+
],
|
|
10
|
+
"main": "./dist/mm-intake-widget.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./dist/mm-intake-widget.js",
|
|
13
|
+
"./widget": "./src/widget.ts",
|
|
14
|
+
"./protocol": "./src/protocol.ts",
|
|
15
|
+
"./signing": "./src/signing.ts",
|
|
16
|
+
"./loader-config": "./src/loader-config.ts"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"eslint": "^9.39.1",
|
|
23
|
+
"lucide-react-native": "^0.562.0",
|
|
24
|
+
"typescript": "~5.9.3",
|
|
25
|
+
"vite": "^7.3.6",
|
|
26
|
+
"@mmcp/eslint-config": "0.1.0",
|
|
27
|
+
"@mmcp/tsconfig": "0.1.0"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "pnpm run build:bundle",
|
|
31
|
+
"build:bundle": "node scripts/generate-icons.mjs && vite build",
|
|
32
|
+
"generate:icons": "node scripts/generate-icons.mjs",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"lint": "eslint \"src/**/*.ts\" --quiet",
|
|
35
|
+
"lint:fix": "eslint \"src/**/*.ts\" --quiet --fix"
|
|
36
|
+
}
|
|
37
|
+
}
|