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

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;
27
72
  }
28
73
  };
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) => {
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;
86
+ }
87
+ const value = decodeCookieValue(raw);
88
+ const parts = value.split(".");
89
+ return parts.length >= 4 ? parts.slice(-2).join(".") : value;
90
+ };
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,84 @@
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
+ };
363
+ const playPromptSound = async () => {
364
+ try {
365
+ const context = new AudioContext();
366
+ const oscillator = context.createOscillator();
367
+ const gain = context.createGain();
368
+ oscillator.frequency.setValueAtTime(660, context.currentTime);
369
+ oscillator.frequency.exponentialRampToValueAtTime(880, context.currentTime + 0.16);
370
+ gain.gain.setValueAtTime(0.08, context.currentTime);
371
+ gain.gain.exponentialRampToValueAtTime(1e-3, context.currentTime + 0.2);
372
+ oscillator.connect(gain);
373
+ gain.connect(context.destination);
374
+ oscillator.start();
375
+ oscillator.stop(context.currentTime + 0.2);
376
+ oscillator.addEventListener("ended", () => void context.close());
377
+ await context.resume();
378
+ } catch {
379
+ }
380
+ };
260
381
  const mountIntakeWidget = (config, onDestroy) => {
261
382
  var _a;
262
383
  const mount = document.createElement("div");
263
384
  mount.dataset.mmIntakeWidget = "";
264
385
  const shadow = mount.attachShadow({ mode: "open" });
265
- const elements = renderWidgetShell(shadow);
386
+ const assetUrl = (fileName) => {
387
+ var _a2;
388
+ return new URL(fileName, (_a2 = config.assetBaseUrl) != null ? _a2 : `${config.chatOrigin}/intake-widget/`).href;
389
+ };
390
+ const elements = renderWidgetShell(shadow, assetUrl("button-text-us.png"), assetUrl("consultation-prompt.webp"));
266
391
  document.body.append(mount);
267
392
  if (typeof mount.showPopover === "function") {
268
393
  mount.setAttribute("popover", "manual");
@@ -272,26 +397,18 @@
272
397
  } catch {
273
398
  }
274
399
  }
275
- const completedSigningEnvelopeId = readCompletedSigningEnvelopeId();
400
+ const returnedFromSigning = hasCompletedSigningEvent();
276
401
  let chatHasLoaded = false;
277
402
  let chatCloseTimer;
278
403
  let chatReadyTimer;
404
+ let promptTimer;
405
+ let promptReturnFocus = null;
279
406
  let isKeyboardOpen = false;
280
407
  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
408
  const syncLauncherLabel = () => {
292
409
  const isOpen = elements.chatDialog.dataset.state === "open";
293
410
  elements.launcher.setAttribute("aria-expanded", String(isOpen));
294
- elements.launcher.setAttribute("aria-label", getLauncherLabel(isOpen, hasUnread));
411
+ elements.launcher.setAttribute("aria-label", isOpen ? "Close intake chat" : "Open intake chat");
295
412
  };
296
413
  const postToChat = (message) => {
297
414
  var _a2;
@@ -303,11 +420,25 @@
303
420
  const hostUrl = buildSigningReturnUrl(currentUrl);
304
421
  postToChat(
305
422
  createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.HOST_READY, {
423
+ // Marketing attribution (MX2-25162): the marketing site's first-touch
424
+ // cookies and the page the chat opened on are only visible from this
425
+ // page, never from the cross-origin chat frame. The chat forwards them
426
+ // on its first /chat turn and Salesforce builds the intake's
427
+ // Marketing_Tracking_Info__c record from them.
428
+ attribution: buildHostAttribution(),
306
429
  ...hostUrl === null ? {} : { hostUrl },
307
- ...completedEnvelopeId === null ? {} : { completedEnvelopeId }
430
+ ...completedEnvelopeId === null ? {} : { completedEnvelopeId },
431
+ // Sent even without an envelope id: the Salesforce service is what
432
+ // appends `envelope_id` to the return URL, and the prod org has shipped
433
+ // without that append. The chat's signing card falls back to its own
434
+ // envelope id on this flag.
435
+ ...hasCompletedSigningEvent(currentUrl) ? { signingComplete: true } : {}
308
436
  })
309
437
  );
310
438
  };
439
+ const focusChatComposer = () => {
440
+ postToChat(createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.FOCUS_COMPOSER));
441
+ };
311
442
  const answerAuthTokenRequest = async () => {
312
443
  var _a2;
313
444
  let token;
@@ -330,6 +461,34 @@
330
461
  chatReadyTimer = void 0;
331
462
  elements.chatLoading.hidden = true;
332
463
  };
464
+ const dismissPrompt = (returnFocus = false) => {
465
+ globalThis.clearTimeout(promptTimer);
466
+ promptTimer = void 0;
467
+ const wasVisible = !elements.promptBackdrop.hidden;
468
+ elements.promptBackdrop.hidden = true;
469
+ elements.promptBackdrop.setAttribute("inert", "");
470
+ rememberPromptDismissal();
471
+ if (returnFocus && wasVisible) {
472
+ const focusTarget = (promptReturnFocus == null ? void 0 : promptReturnFocus.isConnected) === true ? promptReturnFocus : elements.launcher;
473
+ promptReturnFocus = null;
474
+ focusTarget.focus();
475
+ }
476
+ };
477
+ const showPrompt = () => {
478
+ var _a2, _b, _c;
479
+ globalThis.clearTimeout(promptTimer);
480
+ promptTimer = void 0;
481
+ const host = globalThis;
482
+ const mxPopup = (_c = (_a2 = host.drupalSettings) == null ? void 0 : _a2.mxPopup) != null ? _c : (_b = host.mxChatbotSettings) == null ? void 0 : _b.mxPopup;
483
+ if (mxPopup === false || hasPromptCooldown() || elements.chatDialog.dataset.state === "open") {
484
+ return;
485
+ }
486
+ promptReturnFocus = document.activeElement instanceof HTMLElement && document.activeElement !== mount ? document.activeElement : null;
487
+ elements.promptBackdrop.hidden = false;
488
+ elements.promptBackdrop.removeAttribute("inert");
489
+ elements.promptClose.focus();
490
+ void playPromptSound();
491
+ };
333
492
  const syncViewport = () => {
334
493
  const viewport = globalThis.visualViewport;
335
494
  if (!viewport || viewport.height === 0) {
@@ -448,19 +607,21 @@
448
607
  stopViewportTracking();
449
608
  };
450
609
  const openChat = () => {
610
+ dismissPrompt();
451
611
  globalThis.clearTimeout(chatCloseTimer);
452
612
  chatCloseTimer = void 0;
453
613
  elements.chatDialog.hidden = false;
454
614
  elements.chatDialog.dataset.state = "open";
455
615
  elements.chatDialog.removeAttribute("aria-hidden");
456
616
  elements.chatDialog.removeAttribute("inert");
457
- setHasUnread(false);
617
+ syncLauncherLabel();
458
618
  startViewportTracking();
459
619
  ensureChatLoaded();
460
620
  elements.chatFrame.focus();
461
621
  writeOpenState(true);
462
622
  if (chatHasLoaded) {
463
623
  postHostReady();
624
+ focusChatComposer();
464
625
  }
465
626
  };
466
627
  const closeChat = () => {
@@ -496,12 +657,19 @@
496
657
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.CHILD_READY)) {
497
658
  revealChat();
498
659
  postHostReady();
660
+ if (elements.chatDialog.dataset.state === "open") {
661
+ focusChatComposer();
662
+ }
499
663
  return;
500
664
  }
501
665
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.AUTH_TOKEN_REQUEST)) {
502
666
  void answerAuthTokenRequest();
503
667
  return;
504
668
  }
669
+ if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.GET_ATTRIBUTION)) {
670
+ postToChat(createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.ATTRIBUTION, { attribution: buildHostAttribution() }));
671
+ return;
672
+ }
505
673
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.CARET_STATE)) {
506
674
  if (message.payload.isFocused === true) {
507
675
  hasReleasedKeyboardEarly = false;
@@ -511,9 +679,6 @@
511
679
  return;
512
680
  }
513
681
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.MESSAGE_RECEIVED)) {
514
- if (elements.chatDialog.dataset.state !== "open") {
515
- setHasUnread(true);
516
- }
517
682
  return;
518
683
  }
519
684
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.SIGNING_REQUESTED)) {
@@ -526,9 +691,24 @@
526
691
  }
527
692
  };
528
693
  const handleKeydown = (event) => {
694
+ if (event.key === "Tab" && !elements.promptBackdrop.hidden) {
695
+ const { activeElement } = shadow;
696
+ if (!event.shiftKey && activeElement === elements.promptClose) {
697
+ event.preventDefault();
698
+ elements.promptOpen.focus();
699
+ } else if (event.shiftKey && activeElement === elements.promptOpen) {
700
+ event.preventDefault();
701
+ elements.promptClose.focus();
702
+ }
703
+ return;
704
+ }
529
705
  if (event.key !== "Escape") {
530
706
  return;
531
707
  }
708
+ if (!elements.promptBackdrop.hidden) {
709
+ dismissPrompt(true);
710
+ return;
711
+ }
532
712
  if (elements.chatDialog.dataset.state === "open") {
533
713
  closeChat();
534
714
  }
@@ -537,6 +717,7 @@
537
717
  stopViewportTracking();
538
718
  globalThis.clearTimeout(chatCloseTimer);
539
719
  globalThis.clearTimeout(chatReadyTimer);
720
+ globalThis.clearTimeout(promptTimer);
540
721
  globalThis.removeEventListener("message", handleMessage);
541
722
  document.removeEventListener("keydown", handleKeydown);
542
723
  mount.remove();
@@ -549,6 +730,13 @@
549
730
  openChat();
550
731
  }
551
732
  });
733
+ elements.promptOpen.addEventListener("click", openChat);
734
+ elements.promptClose.addEventListener("click", () => dismissPrompt(true));
735
+ elements.promptBackdrop.addEventListener("click", (event) => {
736
+ if (event.target === elements.promptBackdrop) {
737
+ dismissPrompt(true);
738
+ }
739
+ });
552
740
  elements.chatDialog.addEventListener("animationend", (event) => {
553
741
  if (event.target === elements.chatDialog) {
554
742
  finishChatClose();
@@ -562,14 +750,17 @@
562
750
  });
563
751
  globalThis.addEventListener("message", handleMessage);
564
752
  document.addEventListener("keydown", handleKeydown);
565
- loadUnreadIndicator();
566
- if (completedSigningEnvelopeId !== null || readOpenState()) {
753
+ syncLauncherLabel();
754
+ if (returnedFromSigning || readOpenState()) {
567
755
  openChat();
756
+ } else if (!hasPromptCooldown()) {
757
+ promptTimer = globalThis.setTimeout(showPrompt, PROMPT_DELAY_MS);
568
758
  }
569
759
  return Object.freeze({
570
760
  close: closeChat,
571
761
  destroy,
572
762
  open: openChat,
763
+ showPrompt,
573
764
  version: EMBED_PROTOCOL_VERSION
574
765
  });
575
766
  };
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.10",
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",