@morgan-and-morgan/intake-agent 1.0.0-alpha.1 → 1.0.0-alpha.11

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 CHANGED
@@ -21,6 +21,10 @@ placeholder, don't bump it by hand.
21
21
  | `release/**` | `x.y.z-beta.N` | `beta` |
22
22
  | `main` | `x.y.z` | `latest` |
23
23
 
24
+ Each bundle also carries its matching chat environment: development for
25
+ `alpha`, UAT for `beta`, and production for `latest`. `data-chat-url` remains
26
+ an explicit override.
27
+
24
28
  `fix:` bumps the patch, `feat:` the minor, a `BREAKING CHANGE:` footer the
25
29
  major. Every release is tagged `intake-agent-vx.y.z`.
26
30
 
@@ -28,5 +32,11 @@ major. Every release is tagged `intake-agent-vx.y.z`.
28
32
 
29
33
  | Attribute | Default | Purpose |
30
34
  | --- | --- | --- |
31
- | `data-chat-url` | `/embed/intake` | Chat iframe target (resolved against the loader's origin) |
35
+ | `data-chat-url` | Release environment URL | Chat iframe target |
32
36
  | `data-auto-open` | `false` | Open the chat immediately after mount |
37
+
38
+ The consultation modal reads `window.drupalSettings.mxPopup`, falling back to
39
+ `window.mxChatbotSettings.mxPopup` when the Drupal field is absent. Only the
40
+ boolean `false` suppresses the modal, including calls to `showPrompt()`.
41
+ Missing settings preserve the usual display rules. Settings are read when the
42
+ modal is about to appear; the launcher and chat remain available.
Binary file
Binary file
@@ -1,45 +1,133 @@
1
1
  (function() {
2
2
  "use strict";
3
- const DEFAULT_CHAT_PATH = "/embed/intake";
4
3
  const readLoaderConfig = (script, baseUri) => {
5
4
  var _a;
6
5
  const scriptUrl = new URL(script.src, baseUri);
7
- const chatUrl = new URL((_a = script.dataset.chatUrl) != null ? _a : DEFAULT_CHAT_PATH, scriptUrl).href;
6
+ const bundledChatUrl = "https://chat.mx2.dev/embed/intake";
7
+ const chatUrl = new URL((_a = script.dataset.chatUrl) != null ? _a : bundledChatUrl, scriptUrl).href;
8
8
  return {
9
+ assetBaseUrl: new URL(".", scriptUrl).href,
9
10
  autoOpen: script.dataset.autoOpen === "true",
10
11
  chatOrigin: new URL(chatUrl).origin,
11
12
  chatUrl
12
13
  };
13
14
  };
14
- const OPEN_STATE_KEY = "mm-intake-widget-open";
15
- const UNREAD_STATE_KEY = "mm-intake-widget-unread";
16
- const readFlag = (key, storage) => {
15
+ const ATTRIBUTION_FIELD_MAX_LENGTH = {
16
+ tracked_landing_page: 4096,
17
+ tracked_landing_timestamp: 255,
18
+ conversion_page: 255,
19
+ ga_client_id: 255,
20
+ gclid_value: 255,
21
+ marketing_source: 255,
22
+ marketing_sub_source: 255
23
+ };
24
+ const ATTRIBUTION_FIELDS = Object.keys(ATTRIBUTION_FIELD_MAX_LENGTH);
25
+ const sanitizeIntakeChatAttribution = (value) => {
26
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
27
+ return null;
28
+ }
29
+ const record = value;
30
+ const sanitized = {};
31
+ for (const field of ATTRIBUTION_FIELDS) {
32
+ const fieldValue = record[field];
33
+ const trimmed = typeof fieldValue === "string" ? fieldValue.trim() : "";
34
+ if (trimmed !== "") {
35
+ sanitized[field] = trimmed.slice(0, ATTRIBUTION_FIELD_MAX_LENGTH[field]);
36
+ }
37
+ }
38
+ return Object.keys(sanitized).length === 0 ? null : sanitized;
39
+ };
40
+ const LANDING_PAGE_COOKIES = ["trackedLandingPage", "tracked_landing_page"];
41
+ const LANDING_TIMESTAMP_COOKIES = ["trackedLandingPageTimestamp", "tracked_landing_timestamp"];
42
+ const readTimestampCookie = (cookie) => {
43
+ const raw = readFirstCookie(cookie, LANDING_TIMESTAMP_COOKIES);
44
+ return raw === void 0 ? void 0 : decodeCookieValue(raw);
45
+ };
46
+ const readCookie = (cookie, name) => {
47
+ var _a;
48
+ const raw = (_a = cookie.split(";").map((entry) => entry.trim()).find((entry) => entry.startsWith(`${name}=`))) == null ? void 0 : _a.slice(name.length + 1);
49
+ return raw === void 0 || raw === "" ? void 0 : raw;
50
+ };
51
+ const readFirstCookie = (cookie, names) => {
52
+ for (const name of names) {
53
+ const raw = readCookie(cookie, name);
54
+ if (raw !== void 0) {
55
+ return raw;
56
+ }
57
+ }
58
+ return void 0;
59
+ };
60
+ const decodeCookieValue = (raw) => {
17
61
  try {
18
- return storage.getItem(key) === "true";
62
+ return decodeURIComponent(raw);
19
63
  } catch {
20
- return false;
64
+ return raw;
21
65
  }
22
66
  };
23
- const writeFlag = (key, value, storage) => {
67
+ const parseUrl = (value) => {
24
68
  try {
25
- storage.setItem(key, String(value));
69
+ return new URL(value);
26
70
  } catch {
71
+ return null;
72
+ }
73
+ };
74
+ const readLandingPage = (cookie) => {
75
+ const raw = readFirstCookie(cookie, LANDING_PAGE_COOKIES);
76
+ if (raw === void 0 || parseUrl(raw) !== null) {
77
+ return raw;
78
+ }
79
+ const decoded = decodeCookieValue(raw);
80
+ return parseUrl(decoded) === null ? raw : decoded;
81
+ };
82
+ const readGaClientId = (cookie) => {
83
+ const raw = readCookie(cookie, "_ga");
84
+ if (raw === void 0) {
85
+ return void 0;
27
86
  }
87
+ const value = decodeCookieValue(raw);
88
+ const parts = value.split(".");
89
+ return parts.length >= 4 ? parts.slice(-2).join(".") : value;
28
90
  };
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) => {
91
+ const readGclid = (landingUrl) => {
92
+ var _a, _b;
93
+ return landingUrl === void 0 ? void 0 : (_b = (_a = parseUrl(landingUrl)) == null ? void 0 : _a.searchParams.get("gclid")) != null ? _b : void 0;
94
+ };
95
+ const readPathname = (url) => {
96
+ var _a;
97
+ return (_a = parseUrl(url)) == null ? void 0 : _a.pathname;
98
+ };
99
+ const buildHostAttribution = (currentUrl = globalThis.location.href, cookie = document.cookie) => {
100
+ var _a;
101
+ const landingPage = readLandingPage(cookie);
102
+ return (_a = sanitizeIntakeChatAttribution({
103
+ tracked_landing_page: landingPage,
104
+ tracked_landing_timestamp: readTimestampCookie(cookie),
105
+ conversion_page: readPathname(currentUrl),
106
+ ga_client_id: readGaClientId(cookie),
107
+ gclid_value: readGclid(landingPage)
108
+ })) != null ? _a : {};
109
+ };
110
+ const OPEN_STATE_KEY = "mm-intake-widget-open";
111
+ const readOpenState = (storage = sessionStorage) => {
32
112
  try {
33
- return storage.getItem(UNREAD_STATE_KEY) !== "false";
113
+ return storage.getItem(OPEN_STATE_KEY) === "true";
34
114
  } catch {
35
115
  return false;
36
116
  }
37
117
  };
38
- const writeUnreadState = (hasUnread, storage = sessionStorage) => writeFlag(UNREAD_STATE_KEY, hasUnread, storage);
118
+ const writeOpenState = (isOpen, storage = sessionStorage) => {
119
+ try {
120
+ storage.setItem(OPEN_STATE_KEY, String(isOpen));
121
+ } catch {
122
+ }
123
+ };
39
124
  const EMBED_PROTOCOL_VERSION = 1;
40
125
  const EMBED_MESSAGE_TYPE = Object.freeze({
41
126
  HOST_READY: "mm:intake:host-ready",
42
127
  CHILD_READY: "mm:intake:child-ready",
128
+ // Host asks the embedded chat to put the caret in its composer. Kept
129
+ // separate from HOST_READY so signing-card handshakes never steal focus.
130
+ FOCUS_COMPOSER: "mm:intake:focus-composer",
43
131
  SIGNING_REQUESTED: "mm:intake:signing-requested",
44
132
  SIGNING_RECORDED: "mm:intake:signing-recorded",
45
133
  // Chat asks the host page for a signed-in token; the host answers with
@@ -57,11 +145,18 @@
57
145
  // belongs to the focused field, so losing it means the keyboard is already
58
146
  // leaving — which the host's own visual viewport only admits ~500ms later.
59
147
  CARET_STATE: "mm:intake:caret-state",
60
- // Chat tells the host an inbound AI or operator message just landed, so the
61
- // launcher's unread dot can mean something. Payload-free: the dot is a dot,
62
- // and only the chat knows a message arrived while only the host knows whether
63
- // the panel was closed at the time.
64
- MESSAGE_RECEIVED: "mm:intake:message-received"
148
+ // Chat tells the host an inbound AI or operator message just landed. Kept in
149
+ // the protocol for a future unread affordance; the TEXT US sticky has no badge
150
+ // today. Payload-free: only the chat knows a message arrived while only the
151
+ // host knows whether the panel was closed at the time.
152
+ MESSAGE_RECEIVED: "mm:intake:message-received",
153
+ // Chat asks the host for a fresh attribution snapshot right before a /chat
154
+ // turn; the host answers with ATTRIBUTION as `{ attribution }`. The snapshot
155
+ // sent with HOST_READY is read once at mount and races the marketing site's
156
+ // async first-touch cookie write (MX2-26181), so the landing URL — and the
157
+ // gclid parsed out of it — can only be caught by re-reading at send time.
158
+ GET_ATTRIBUTION: "mm:intake:get-attribution",
159
+ ATTRIBUTION: "mm:intake:attribution"
65
160
  });
66
161
  const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
67
162
  const isEmbedProtocolMessage = (value, type) => isRecord(value) && value.type === type && value.version === EMBED_PROTOCOL_VERSION && isRecord(value.payload);
@@ -116,6 +211,13 @@
116
211
  return null;
117
212
  }
118
213
  };
214
+ const hasCompletedSigningEvent = (currentUrl = globalThis.location.href) => {
215
+ try {
216
+ return new URL(currentUrl).searchParams.get("event") === SIGNING_COMPLETE_EVENT;
217
+ } catch {
218
+ return false;
219
+ }
220
+ };
119
221
  const LUCIDE_ICON_PATHS = {
120
222
  "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"/>',
121
223
  "x": '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
@@ -130,98 +232,75 @@
130
232
  };
131
233
  const FULL_PAGE_PANEL_QUERY = "(max-width:600px),(max-height:500px)";
132
234
  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>`;
133
- const CHAT_ICON = lucideIcon("message-circle", "mm-icon mm-icon-chat");
134
235
  const OPEN_CLOSE_ICON = lucideIcon("x", "mm-icon mm-icon-close");
236
+ const PROMPT_CLOSE_ICON = lucideIcon("x", "mm-prompt-close-icon", 2.5);
135
237
  const WIDGET_STYLES = [
136
238
  ':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}',
137
239
  "*,*::before,*::after{box-sizing:border-box}",
138
240
  "button{font:inherit}",
139
- "@keyframes mm-launcher-in{from{opacity:0;transform:translateY(10px) scale(.85)}to{opacity:1;transform:none}}",
241
+ "@keyframes mm-launcher-in{from{opacity:0;transform:translateX(8px) scale(.94)}to{opacity:1;transform:none}}",
242
+ "@keyframes mm-close-in{from{opacity:0;transform:scale(.85)}to{opacity:1;transform:scale(1)}}",
140
243
  "@keyframes mm-panel-in{from{opacity:0;transform:translateY(10px) scale(.98)}to{opacity:1;transform:none}}",
141
244
  "@keyframes mm-panel-out{from{opacity:1;transform:none}to{opacity:0;transform:translateY(10px) scale(.98)}}",
142
- "@keyframes mm-ping{0%{transform:scale(1);opacity:.55}70%,100%{transform:scale(2.4);opacity:0}}",
143
- // Same register as the chat's own typing dots (typing-indicator.tsx): rest at
144
- // .4 opacity, pulse to full with a 1.2x scale, each dot a beat behind the last.
245
+ "@keyframes mm-prompt-backdrop-in{from{opacity:0}to{opacity:1}}",
246
+ "@keyframes mm-prompt-card-in{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}",
145
247
  "@keyframes mm-dot{0%,100%{opacity:.4;transform:scale(1)}50%{opacity:1;transform:scale(1.2)}}",
146
- // The launcher stacks above the chat panel: it is the only close affordance
147
- // (the panel is chrome-free), so the full-screen mobile panel must not cover it.
248
+ // Closed: LivePerson TEXT US sticky under the chat layer. Open: swap for a
249
+ // blue X disc above the chat so there is always a clear close control.
250
+ // Same asset as forthepeople.com (button-text-us-white-bg), already rotated
251
+ // 90deg CW like GTM. Placement matches #messaging_gbm: top 42%, ~38px thick,
252
+ // no translateY(-50%). right:10px keeps a small inset from the viewport edge.
148
253
  // Against the host page the near-max z-index is only the fallback for
149
254
  // browsers without the Popover API — everywhere else the mount rides the top
150
255
  // layer (widget.ts) and outranks any host z-index.
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}",
256
+ ".mm-launcher{position:fixed;right:10px;top:42%;z-index:2147482999;display:grid;place-items:center;width:auto;height:auto;padding:0;border:0;border-radius:0;outline:none;background:transparent;color:var(--mm-ink);box-shadow:none;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-user-drag:none;transform:none;animation:mm-launcher-in .28s cubic-bezier(.23,1,.32,1) both;transition:transform .15s cubic-bezier(.23,1,.32,1),filter .15s ease,background-color .15s ease}",
152
257
  ".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)}",
258
+ ".mm-launcher:hover{transform:translateX(-2px);filter:drop-shadow(0 4px 10px rgba(17,17,17,.18))}",
259
+ ".mm-launcher:active{transform:none;filter:none}",
155
260
  ".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}',
261
+ ".mm-text-us{display:block;width:38px;height:auto;pointer-events:none}",
262
+ ".mm-icon{position:absolute;top:50%;left:50%;display:none;width:22px;height:22px;pointer-events:none;transform:translate(-50%,-50%)}",
263
+ // Open on desktop: X sits on the chat's top-right corner (chat is bottom:24,
264
+ // height min(640px, 100vh - 48px)).
265
+ '.mm-launcher[aria-expanded="true"]{top:calc(100vh - 24px - min(640px,100vh - 48px) + 12px);right:36px;bottom:auto;z-index:2147483001;width:44px;height:44px;border-radius:999px;background:var(--mm-blue);color:var(--mm-white);box-shadow:0 4px 14px rgba(17,17,17,.2);transform:none;animation:mm-close-in .2s cubic-bezier(.23,1,.32,1) both;filter:none}',
266
+ '.mm-launcher[aria-expanded="true"]:hover,.mm-launcher[aria-expanded="true"]:active{transform:none;filter:none;background:var(--mm-blue-hover)}',
267
+ '.mm-launcher[aria-expanded="true"] .mm-text-us{display:none}',
161
268
  '.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.
269
+ ".mm-chat{position:fixed;right:24px;bottom:24px;z-index:2147483000;display:flex;flex-direction:column;width:min(400px,calc(100vw - 32px));height:min(640px,calc(100vh - 48px));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)}",
176
270
  '.mm-chat[data-keyboard="open"]{--mm-panel-bottom:var(--mm-surface-blue)}',
177
271
  '.mm-chat[data-state="closing"]{pointer-events:none;animation-name:mm-panel-out}',
178
272
  '.mm-chat[data-state="closed"]{visibility:hidden;animation-name:none}',
179
273
  ".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
274
  ".mm-frame{display:block;width:100%;height:100%;border:0;background:var(--mm-chrome)}",
184
275
  ".mm-loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:5px;background:var(--mm-chrome)}",
185
276
  ".mm-dot{width:8px;height:8px;border-radius:999px;background:var(--mm-muted);animation:mm-dot 1.2s ease-in-out infinite}",
186
277
  ".mm-dot:nth-child(2){animation-delay:.15s}",
187
278
  ".mm-dot:nth-child(3){animation-delay:.3s}",
279
+ ".mm-prompt-backdrop{position:fixed;inset:0;z-index:2147483002;display:grid;place-items:center;padding:16px;overscroll-behavior:contain;touch-action:none;background:rgba(0,0,0,.64);animation:mm-prompt-backdrop-in .25s cubic-bezier(.23,1,.32,1) both;font-family:var(--mm-font)}",
280
+ ".mm-prompt-card{position:relative;width:min(632px,calc(100vw - 32px),calc((100vh - 32px)*1.26148));aspect-ratio:632/501;animation:mm-prompt-card-in .25s cubic-bezier(.23,1,.32,1) both}",
281
+ ".mm-prompt-image{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}",
282
+ ".mm-prompt-open,.mm-prompt-close{position:absolute;border:0;outline:none;background:transparent;cursor:pointer}",
283
+ ".mm-prompt-open{inset:0;border-radius:24px}",
284
+ ".mm-prompt-close{top:4.5%;right:3.1%;z-index:1;display:grid;place-items:center;width:44px;height:44px;border-radius:999px;color:var(--mm-white)}",
285
+ '.mm-prompt-close::before{content:"";position:absolute;width:28px;height:28px;border-radius:999px;background:#050505;box-shadow:0 1px 4px rgba(0,0,0,.35);transition:background-color .12s ease}',
286
+ ".mm-prompt-close-icon{position:relative;display:block;width:16px;height:16px;pointer-events:none}",
287
+ ".mm-prompt-close:hover::before{background:#252525}",
288
+ ".mm-prompt-open:focus-visible{outline:3px solid var(--mm-yellow);outline-offset:-8px}",
289
+ ".mm-prompt-close:focus-visible{outline:none}",
290
+ ".mm-prompt-close:focus-visible::before{background:#050505;box-shadow:inset 0 0 0 2px var(--mm-yellow),0 1px 4px rgba(0,0,0,.35)}",
188
291
  "[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
- // `left:0;right:0` rather than `width:100vw`: `100vw` counts the classic
204
- // scrollbar the host page still has behind us, so a right-anchored panel that
205
- // wide starts a scrollbar's width off the left edge and clips its own header.
206
- //
207
- // The shadow fills the strip below the panel. Safari's bottom bar is
208
- // translucent once it collapses — scrolled down, or any time the keyboard is
209
- // up — and `visualViewport.height` stops short of it, so the host page shows
210
- // through underneath the chat. `lvh - svh` is exactly that bar's height, and
211
- // it is 0 wherever the browser has no collapsing bar, so this costs nothing
212
- // there. A shadow rather than extra height: the trust line and composer must
213
- // stay above the bar, not slide under it.
214
- `@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{left:0;right:0;top:var(--mm-viewport-top,auto);bottom:0;width:auto;height:var(--mm-viewport-height,100dvh);min-height:0;border:0;border-radius:0;box-shadow:0 calc(100lvh - 100svh) 0 var(--mm-panel-bottom,var(--mm-chrome))}}`,
215
- "@media(prefers-reduced-motion:reduce){.mm-launcher,.mm-chat{animation:none;transition:none}.mm-badge::after,.mm-dot{animation:none}}"
292
+ `@media ${FULL_PAGE_PANEL_QUERY}{.mm-launcher[aria-expanded="true"]{top:calc(var(--mm-viewport-top,0px) + 10px);right:10px;bottom:auto;width:44px;height:44px}.mm-chat{left:0;right:0;top:var(--mm-viewport-top,auto);bottom:0;width:auto;height:var(--mm-viewport-height,100dvh);min-height:0;border:0;border-radius:0;box-shadow:0 calc(100lvh - 100svh) 0 var(--mm-panel-bottom,var(--mm-chrome))}}`,
293
+ "@media(forced-colors:active){.mm-prompt-close:focus-visible{outline:2px solid ButtonText;outline-offset:-9px}}",
294
+ "@media(prefers-reduced-motion:reduce){.mm-launcher,.mm-chat{animation:none;transition:none}.mm-prompt-card{animation-name:mm-prompt-backdrop-in}.mm-dot{animation:none}}"
216
295
  ].join("");
217
- const WIDGET_MARKUP = [
296
+ const widgetMarkup = (launcherImageUrl, promptImageUrl) => [
218
297
  `<style>${WIDGET_STYLES}</style>`,
219
- `<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>`,
298
+ `<button class="mm-launcher" type="button" aria-label="Open intake chat" aria-expanded="false" draggable="false"><img class="mm-text-us" src="${launcherImageUrl}" alt="" width="38" height="147" decoding="async" />${OPEN_CLOSE_ICON}</button>`,
220
299
  '<section class="mm-chat" role="dialog" aria-label="Morgan and Morgan intake chat" inert hidden>',
221
- // Dots rather than a sentence: the panel is up for a second or two and a line
222
- // of copy reads like an error the visitor has to act on. The label carries the
223
- // same information for a screen reader.
224
300
  '<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>',
301
+ "</section>",
302
+ '<section class="mm-prompt-backdrop" role="dialog" aria-modal="true" aria-label="Start your free consultation" inert hidden>',
303
+ `<div class="mm-prompt-card"><img class="mm-prompt-image" src="${promptImageUrl}" alt="Our team is available 24/7. Chat with a real person. Click to start your free consultation." width="632" height="501" decoding="async" /><button class="mm-prompt-open" type="button" aria-label="Message us about a free consultation"></button><button class="mm-prompt-close" type="button" aria-label="Dismiss consultation prompt">${PROMPT_CLOSE_ICON}</button></div>`,
225
304
  "</section>"
226
305
  ].join("");
227
306
  const requireElement = (shadow, selector) => {
@@ -231,38 +310,66 @@
231
310
  }
232
311
  return element;
233
312
  };
234
- const renderWidgetShell = (shadow) => {
235
- shadow.innerHTML = WIDGET_MARKUP;
313
+ const renderWidgetShell = (shadow, launcherImageUrl, promptImageUrl) => {
314
+ shadow.innerHTML = widgetMarkup(launcherImageUrl, promptImageUrl);
236
315
  return {
237
316
  chatDialog: requireElement(shadow, ".mm-chat"),
238
317
  chatFrame: requireElement(shadow, ".mm-chat-frame"),
239
318
  chatLoading: requireElement(shadow, ".mm-loading"),
240
319
  launcher: requireElement(shadow, ".mm-launcher"),
241
- unreadBadge: requireElement(shadow, ".mm-badge")
320
+ promptBackdrop: requireElement(shadow, ".mm-prompt-backdrop"),
321
+ promptClose: requireElement(shadow, ".mm-prompt-close"),
322
+ promptOpen: requireElement(shadow, ".mm-prompt-open")
242
323
  };
243
324
  };
244
325
  const PANEL_ANIMATION_MS = 180;
326
+ const PROMPT_DELAY_MS = 5e3;
327
+ const PROMPT_COOLDOWN_MS = 24 * 60 * 60 * 1e3;
328
+ const PROMPT_COOLDOWN_KEY = "mm-intake-widget-prompt-dismissed-at";
329
+ const PROMPT_SESSION_KEY = "mm-intake-widget-prompt-dismissed";
245
330
  const CHAT_CHROME_COLOR = "#e8eef2";
246
331
  const KEYBOARD_MIN_SHRINK_PX = 100;
247
332
  const CHAT_READY_FALLBACK_MS = 4e3;
248
- const getLauncherLabel = (isOpen, hasUnread) => {
249
- if (isOpen) {
250
- return "Close intake chat";
251
- }
252
- return hasUnread ? "Open intake chat, new message" : "Open intake chat";
253
- };
254
333
  const cleanSigningReturnUrl = () => {
255
334
  const cleanedReturnUrl = buildSigningReturnUrl();
256
335
  if (cleanedReturnUrl !== null) {
257
336
  globalThis.history.replaceState(globalThis.history.state, "", cleanedReturnUrl);
258
337
  }
259
338
  };
339
+ const hasPromptCooldown = () => {
340
+ try {
341
+ if (globalThis.sessionStorage.getItem(PROMPT_SESSION_KEY) === "true") {
342
+ return true;
343
+ }
344
+ } catch {
345
+ }
346
+ try {
347
+ const dismissedAt = Number(globalThis.localStorage.getItem(PROMPT_COOLDOWN_KEY));
348
+ return Number.isFinite(dismissedAt) && dismissedAt > 0 && Date.now() - dismissedAt < PROMPT_COOLDOWN_MS;
349
+ } catch {
350
+ return false;
351
+ }
352
+ };
353
+ const rememberPromptDismissal = () => {
354
+ try {
355
+ globalThis.sessionStorage.setItem(PROMPT_SESSION_KEY, "true");
356
+ } catch {
357
+ }
358
+ try {
359
+ globalThis.localStorage.setItem(PROMPT_COOLDOWN_KEY, String(Date.now()));
360
+ } catch {
361
+ }
362
+ };
260
363
  const mountIntakeWidget = (config, onDestroy) => {
261
364
  var _a;
262
365
  const mount = document.createElement("div");
263
366
  mount.dataset.mmIntakeWidget = "";
264
367
  const shadow = mount.attachShadow({ mode: "open" });
265
- const elements = renderWidgetShell(shadow);
368
+ const assetUrl = (fileName) => {
369
+ var _a2;
370
+ return new URL(fileName, (_a2 = config.assetBaseUrl) != null ? _a2 : `${config.chatOrigin}/intake-widget/`).href;
371
+ };
372
+ const elements = renderWidgetShell(shadow, assetUrl("button-text-us.png"), assetUrl("consultation-prompt.webp"));
266
373
  document.body.append(mount);
267
374
  if (typeof mount.showPopover === "function") {
268
375
  mount.setAttribute("popover", "manual");
@@ -272,26 +379,18 @@
272
379
  } catch {
273
380
  }
274
381
  }
275
- const completedSigningEnvelopeId = readCompletedSigningEnvelopeId();
382
+ const returnedFromSigning = hasCompletedSigningEvent();
276
383
  let chatHasLoaded = false;
277
384
  let chatCloseTimer;
278
385
  let chatReadyTimer;
386
+ let promptTimer;
387
+ let promptReturnFocus = null;
279
388
  let isKeyboardOpen = false;
280
389
  let hasReleasedKeyboardEarly = false;
281
- let hasUnread = readUnreadState();
282
- const loadUnreadIndicator = () => {
283
- elements.unreadBadge.hidden = !hasUnread;
284
- syncLauncherLabel();
285
- };
286
- const setHasUnread = (next) => {
287
- hasUnread = next;
288
- writeUnreadState(next);
289
- loadUnreadIndicator();
290
- };
291
390
  const syncLauncherLabel = () => {
292
391
  const isOpen = elements.chatDialog.dataset.state === "open";
293
392
  elements.launcher.setAttribute("aria-expanded", String(isOpen));
294
- elements.launcher.setAttribute("aria-label", getLauncherLabel(isOpen, hasUnread));
393
+ elements.launcher.setAttribute("aria-label", isOpen ? "Close intake chat" : "Open intake chat");
295
394
  };
296
395
  const postToChat = (message) => {
297
396
  var _a2;
@@ -303,11 +402,25 @@
303
402
  const hostUrl = buildSigningReturnUrl(currentUrl);
304
403
  postToChat(
305
404
  createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.HOST_READY, {
405
+ // Marketing attribution (MX2-25162): the marketing site's first-touch
406
+ // cookies and the page the chat opened on are only visible from this
407
+ // page, never from the cross-origin chat frame. The chat forwards them
408
+ // on its first /chat turn and Salesforce builds the intake's
409
+ // Marketing_Tracking_Info__c record from them.
410
+ attribution: buildHostAttribution(),
306
411
  ...hostUrl === null ? {} : { hostUrl },
307
- ...completedEnvelopeId === null ? {} : { completedEnvelopeId }
412
+ ...completedEnvelopeId === null ? {} : { completedEnvelopeId },
413
+ // Sent even without an envelope id: the Salesforce service is what
414
+ // appends `envelope_id` to the return URL, and the prod org has shipped
415
+ // without that append. The chat's signing card falls back to its own
416
+ // envelope id on this flag.
417
+ ...hasCompletedSigningEvent(currentUrl) ? { signingComplete: true } : {}
308
418
  })
309
419
  );
310
420
  };
421
+ const focusChatComposer = () => {
422
+ postToChat(createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.FOCUS_COMPOSER));
423
+ };
311
424
  const answerAuthTokenRequest = async () => {
312
425
  var _a2;
313
426
  let token;
@@ -330,6 +443,33 @@
330
443
  chatReadyTimer = void 0;
331
444
  elements.chatLoading.hidden = true;
332
445
  };
446
+ const dismissPrompt = (returnFocus = false) => {
447
+ globalThis.clearTimeout(promptTimer);
448
+ promptTimer = void 0;
449
+ const wasVisible = !elements.promptBackdrop.hidden;
450
+ elements.promptBackdrop.hidden = true;
451
+ elements.promptBackdrop.setAttribute("inert", "");
452
+ rememberPromptDismissal();
453
+ if (returnFocus && wasVisible) {
454
+ const focusTarget = (promptReturnFocus == null ? void 0 : promptReturnFocus.isConnected) === true ? promptReturnFocus : elements.launcher;
455
+ promptReturnFocus = null;
456
+ focusTarget.focus();
457
+ }
458
+ };
459
+ const showPrompt = () => {
460
+ var _a2, _b, _c;
461
+ globalThis.clearTimeout(promptTimer);
462
+ promptTimer = void 0;
463
+ const host = globalThis;
464
+ const mxPopup = (_c = (_a2 = host.drupalSettings) == null ? void 0 : _a2.mxPopup) != null ? _c : (_b = host.mxChatbotSettings) == null ? void 0 : _b.mxPopup;
465
+ if (mxPopup === false || hasPromptCooldown() || elements.chatDialog.dataset.state === "open") {
466
+ return;
467
+ }
468
+ promptReturnFocus = document.activeElement instanceof HTMLElement && document.activeElement !== mount ? document.activeElement : null;
469
+ elements.promptBackdrop.hidden = false;
470
+ elements.promptBackdrop.removeAttribute("inert");
471
+ elements.promptClose.focus();
472
+ };
333
473
  const syncViewport = () => {
334
474
  const viewport = globalThis.visualViewport;
335
475
  if (!viewport || viewport.height === 0) {
@@ -448,19 +588,21 @@
448
588
  stopViewportTracking();
449
589
  };
450
590
  const openChat = () => {
591
+ dismissPrompt();
451
592
  globalThis.clearTimeout(chatCloseTimer);
452
593
  chatCloseTimer = void 0;
453
594
  elements.chatDialog.hidden = false;
454
595
  elements.chatDialog.dataset.state = "open";
455
596
  elements.chatDialog.removeAttribute("aria-hidden");
456
597
  elements.chatDialog.removeAttribute("inert");
457
- setHasUnread(false);
598
+ syncLauncherLabel();
458
599
  startViewportTracking();
459
600
  ensureChatLoaded();
460
601
  elements.chatFrame.focus();
461
602
  writeOpenState(true);
462
603
  if (chatHasLoaded) {
463
604
  postHostReady();
605
+ focusChatComposer();
464
606
  }
465
607
  };
466
608
  const closeChat = () => {
@@ -496,12 +638,19 @@
496
638
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.CHILD_READY)) {
497
639
  revealChat();
498
640
  postHostReady();
641
+ if (elements.chatDialog.dataset.state === "open") {
642
+ focusChatComposer();
643
+ }
499
644
  return;
500
645
  }
501
646
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.AUTH_TOKEN_REQUEST)) {
502
647
  void answerAuthTokenRequest();
503
648
  return;
504
649
  }
650
+ if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.GET_ATTRIBUTION)) {
651
+ postToChat(createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.ATTRIBUTION, { attribution: buildHostAttribution() }));
652
+ return;
653
+ }
505
654
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.CARET_STATE)) {
506
655
  if (message.payload.isFocused === true) {
507
656
  hasReleasedKeyboardEarly = false;
@@ -511,9 +660,6 @@
511
660
  return;
512
661
  }
513
662
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.MESSAGE_RECEIVED)) {
514
- if (elements.chatDialog.dataset.state !== "open") {
515
- setHasUnread(true);
516
- }
517
663
  return;
518
664
  }
519
665
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.SIGNING_REQUESTED)) {
@@ -526,9 +672,24 @@
526
672
  }
527
673
  };
528
674
  const handleKeydown = (event) => {
675
+ if (event.key === "Tab" && !elements.promptBackdrop.hidden) {
676
+ const { activeElement } = shadow;
677
+ if (!event.shiftKey && activeElement === elements.promptClose) {
678
+ event.preventDefault();
679
+ elements.promptOpen.focus();
680
+ } else if (event.shiftKey && activeElement === elements.promptOpen) {
681
+ event.preventDefault();
682
+ elements.promptClose.focus();
683
+ }
684
+ return;
685
+ }
529
686
  if (event.key !== "Escape") {
530
687
  return;
531
688
  }
689
+ if (!elements.promptBackdrop.hidden) {
690
+ dismissPrompt(true);
691
+ return;
692
+ }
532
693
  if (elements.chatDialog.dataset.state === "open") {
533
694
  closeChat();
534
695
  }
@@ -537,6 +698,7 @@
537
698
  stopViewportTracking();
538
699
  globalThis.clearTimeout(chatCloseTimer);
539
700
  globalThis.clearTimeout(chatReadyTimer);
701
+ globalThis.clearTimeout(promptTimer);
540
702
  globalThis.removeEventListener("message", handleMessage);
541
703
  document.removeEventListener("keydown", handleKeydown);
542
704
  mount.remove();
@@ -549,6 +711,13 @@
549
711
  openChat();
550
712
  }
551
713
  });
714
+ elements.promptOpen.addEventListener("click", openChat);
715
+ elements.promptClose.addEventListener("click", () => dismissPrompt(true));
716
+ elements.promptBackdrop.addEventListener("click", (event) => {
717
+ if (event.target === elements.promptBackdrop) {
718
+ dismissPrompt(true);
719
+ }
720
+ });
552
721
  elements.chatDialog.addEventListener("animationend", (event) => {
553
722
  if (event.target === elements.chatDialog) {
554
723
  finishChatClose();
@@ -562,14 +731,17 @@
562
731
  });
563
732
  globalThis.addEventListener("message", handleMessage);
564
733
  document.addEventListener("keydown", handleKeydown);
565
- loadUnreadIndicator();
566
- if (completedSigningEnvelopeId !== null || readOpenState()) {
734
+ syncLauncherLabel();
735
+ if (returnedFromSigning || readOpenState()) {
567
736
  openChat();
737
+ } else if (!hasPromptCooldown()) {
738
+ promptTimer = globalThis.setTimeout(showPrompt, PROMPT_DELAY_MS);
568
739
  }
569
740
  return Object.freeze({
570
741
  close: closeChat,
571
742
  destroy,
572
743
  open: openChat,
744
+ showPrompt,
573
745
  version: EMBED_PROTOCOL_VERSION
574
746
  });
575
747
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morgan-and-morgan/intake-agent",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.11",
4
4
  "description": "Embeddable chat widget loader.",
5
5
  "license": "UNLICENSED",
6
6
  "files": [
@@ -11,6 +11,7 @@
11
11
  "exports": {
12
12
  ".": "./dist/mm-intake-widget.js",
13
13
  "./widget": "./src/widget.ts",
14
+ "./attribution": "./src/attribution.ts",
14
15
  "./protocol": "./src/protocol.ts",
15
16
  "./signing": "./src/signing.ts",
16
17
  "./loader-config": "./src/loader-config.ts"
@@ -22,6 +23,7 @@
22
23
  "build": "pnpm run build:bundle",
23
24
  "build:bundle": "node scripts/generate-icons.mjs && vite build",
24
25
  "generate:icons": "node scripts/generate-icons.mjs",
26
+ "test": "node --test 'src/*.test.mjs'",
25
27
  "typecheck": "tsc --noEmit",
26
28
  "lint": "eslint \"src/**/*.ts\" --quiet",
27
29
  "lint:fix": "eslint \"src/**/*.ts\" --quiet --fix",