@morgan-and-morgan/intake-agent 0.1.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
@@ -9,9 +9,34 @@ Load the widget with a script tag, pinning an exact version:
9
9
  ></script>
10
10
  ```
11
11
 
12
+ ## Versions
13
+
14
+ semantic-release publishes on every merge that touches this package and picks
15
+ the version from the commit messages, so the `version` in `package.json` is a
16
+ placeholder, don't bump it by hand.
17
+
18
+ | Branch | Version | dist-tag |
19
+ | --- | --- | --- |
20
+ | `develop` | `x.y.z-alpha.N` | `alpha` |
21
+ | `release/**` | `x.y.z-beta.N` | `beta` |
22
+ | `main` | `x.y.z` | `latest` |
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
+
28
+ `fix:` bumps the patch, `feat:` the minor, a `BREAKING CHANGE:` footer the
29
+ major. Every release is tagged `intake-agent-vx.y.z`.
30
+
12
31
  ## Attributes
13
32
 
14
33
  | Attribute | Default | Purpose |
15
34
  | --- | --- | --- |
16
- | `data-chat-url` | `/embed/intake` | Chat iframe target (resolved against the loader's origin) |
35
+ | `data-chat-url` | Release environment URL | Chat iframe target |
17
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,41 +1,123 @@
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) => readFlag(UNREAD_STATE_KEY, storage);
32
- const writeUnreadState = (hasUnread, storage = sessionStorage) => {
33
- if (hasUnread) {
34
- writeFlag(UNREAD_STATE_KEY, true, storage);
35
- return;
74
+ const readLandingPage = (cookie) => {
75
+ const raw = readFirstCookie(cookie, LANDING_PAGE_COOKIES);
76
+ if (raw === void 0 || parseUrl(raw) !== null) {
77
+ return raw;
36
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) => {
37
112
  try {
38
- storage.removeItem(UNREAD_STATE_KEY);
113
+ return storage.getItem(OPEN_STATE_KEY) === "true";
114
+ } catch {
115
+ return false;
116
+ }
117
+ };
118
+ const writeOpenState = (isOpen, storage = sessionStorage) => {
119
+ try {
120
+ storage.setItem(OPEN_STATE_KEY, String(isOpen));
39
121
  } catch {
40
122
  }
41
123
  };
@@ -43,6 +125,9 @@
43
125
  const EMBED_MESSAGE_TYPE = Object.freeze({
44
126
  HOST_READY: "mm:intake:host-ready",
45
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",
46
131
  SIGNING_REQUESTED: "mm:intake:signing-requested",
47
132
  SIGNING_RECORDED: "mm:intake:signing-recorded",
48
133
  // Chat asks the host page for a signed-in token; the host answers with
@@ -60,11 +145,18 @@
60
145
  // belongs to the focused field, so losing it means the keyboard is already
61
146
  // leaving — which the host's own visual viewport only admits ~500ms later.
62
147
  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"
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"
68
160
  });
69
161
  const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
70
162
  const isEmbedProtocolMessage = (value, type) => isRecord(value) && value.type === type && value.version === EMBED_PROTOCOL_VERSION && isRecord(value.payload);
@@ -119,6 +211,13 @@
119
211
  return null;
120
212
  }
121
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
+ };
122
221
  const LUCIDE_ICON_PATHS = {
123
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"/>',
124
223
  "x": '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
@@ -133,84 +232,75 @@
133
232
  };
134
233
  const FULL_PAGE_PANEL_QUERY = "(max-width:600px),(max-height:500px)";
135
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>`;
136
- const CHAT_ICON = lucideIcon("message-circle", "mm-icon mm-icon-chat");
137
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);
138
237
  const WIDGET_STYLES = [
139
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}',
140
239
  "*,*::before,*::after{box-sizing:border-box}",
141
240
  "button{font:inherit}",
142
- "@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)}}",
143
243
  "@keyframes mm-panel-in{from{opacity:0;transform:translateY(10px) scale(.98)}to{opacity:1;transform:none}}",
144
244
  "@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.
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}}",
148
247
  "@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}",
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.
253
+ // Against the host page the near-max z-index is only the fallback for
254
+ // browsers without the Popover API — everywhere else the mount rides the top
255
+ // layer (widget.ts) and outranks any host z-index.
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
- `@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}}"
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}}"
205
295
  ].join("");
206
- const WIDGET_MARKUP = [
296
+ const widgetMarkup = (launcherImageUrl, promptImageUrl) => [
207
297
  `<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>`,
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>`,
209
299
  '<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
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>`,
214
304
  "</section>"
215
305
  ].join("");
216
306
  const requireElement = (shadow, selector) => {
@@ -220,56 +310,105 @@
220
310
  }
221
311
  return element;
222
312
  };
223
- const renderWidgetShell = (shadow) => {
224
- shadow.innerHTML = WIDGET_MARKUP;
313
+ const renderWidgetShell = (shadow, launcherImageUrl, promptImageUrl) => {
314
+ shadow.innerHTML = widgetMarkup(launcherImageUrl, promptImageUrl);
225
315
  return {
226
316
  chatDialog: requireElement(shadow, ".mm-chat"),
227
317
  chatFrame: requireElement(shadow, ".mm-chat-frame"),
228
318
  chatLoading: requireElement(shadow, ".mm-loading"),
229
319
  launcher: requireElement(shadow, ".mm-launcher"),
230
- 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")
231
323
  };
232
324
  };
233
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";
234
330
  const CHAT_CHROME_COLOR = "#e8eef2";
235
331
  const KEYBOARD_MIN_SHRINK_PX = 100;
236
332
  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
333
  const cleanSigningReturnUrl = () => {
244
334
  const cleanedReturnUrl = buildSigningReturnUrl();
245
335
  if (cleanedReturnUrl !== null) {
246
336
  globalThis.history.replaceState(globalThis.history.state, "", cleanedReturnUrl);
247
337
  }
248
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
+ };
249
381
  const mountIntakeWidget = (config, onDestroy) => {
250
382
  var _a;
251
383
  const mount = document.createElement("div");
252
384
  mount.dataset.mmIntakeWidget = "";
253
385
  const shadow = mount.attachShadow({ mode: "open" });
254
- 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"));
255
391
  document.body.append(mount);
256
- const completedSigningEnvelopeId = readCompletedSigningEnvelopeId();
392
+ if (typeof mount.showPopover === "function") {
393
+ mount.setAttribute("popover", "manual");
394
+ mount.style.cssText = "position:fixed;inset:0 auto auto 0;width:0;height:0;margin:0;border:0;padding:0;overflow:visible;background:transparent";
395
+ try {
396
+ mount.showPopover();
397
+ } catch {
398
+ }
399
+ }
400
+ const returnedFromSigning = hasCompletedSigningEvent();
257
401
  let chatHasLoaded = false;
258
402
  let chatCloseTimer;
259
403
  let chatReadyTimer;
404
+ let promptTimer;
405
+ let promptReturnFocus = null;
260
406
  let isKeyboardOpen = false;
261
407
  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
408
  const syncLauncherLabel = () => {
270
409
  const isOpen = elements.chatDialog.dataset.state === "open";
271
410
  elements.launcher.setAttribute("aria-expanded", String(isOpen));
272
- elements.launcher.setAttribute("aria-label", getLauncherLabel(isOpen, hasUnread));
411
+ elements.launcher.setAttribute("aria-label", isOpen ? "Close intake chat" : "Open intake chat");
273
412
  };
274
413
  const postToChat = (message) => {
275
414
  var _a2;
@@ -281,11 +420,25 @@
281
420
  const hostUrl = buildSigningReturnUrl(currentUrl);
282
421
  postToChat(
283
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(),
284
429
  ...hostUrl === null ? {} : { hostUrl },
285
- ...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 } : {}
286
436
  })
287
437
  );
288
438
  };
439
+ const focusChatComposer = () => {
440
+ postToChat(createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.FOCUS_COMPOSER));
441
+ };
289
442
  const answerAuthTokenRequest = async () => {
290
443
  var _a2;
291
444
  let token;
@@ -308,6 +461,34 @@
308
461
  chatReadyTimer = void 0;
309
462
  elements.chatLoading.hidden = true;
310
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
+ };
311
492
  const syncViewport = () => {
312
493
  const viewport = globalThis.visualViewport;
313
494
  if (!viewport || viewport.height === 0) {
@@ -338,9 +519,11 @@
338
519
  let createdThemeColor = null;
339
520
  const hostRoots = [document.documentElement, document.body];
340
521
  let hostBackgroundColorBackup = null;
522
+ let hostOverflowBackup = null;
341
523
  const borrowHostChrome = () => {
342
524
  if (themeColorBackup === null) {
343
525
  hostBackgroundColorBackup = hostRoots.map((root) => root.style.backgroundColor);
526
+ hostOverflowBackup = hostRoots.map((root) => root.style.overflow);
344
527
  const metas = [...document.head.querySelectorAll('meta[name="theme-color"]')];
345
528
  if (metas.length === 0) {
346
529
  createdThemeColor = document.createElement("meta");
@@ -352,13 +535,14 @@
352
535
  }
353
536
  for (const root of hostRoots) {
354
537
  root.style.setProperty("background-color", CHAT_CHROME_COLOR, "important");
538
+ root.style.setProperty("overflow", "hidden", "important");
355
539
  }
356
540
  for (const { meta } of themeColorBackup) {
357
541
  meta.content = CHAT_CHROME_COLOR;
358
542
  }
359
543
  };
360
544
  const restoreHostChrome = () => {
361
- var _a2;
545
+ var _a2, _b;
362
546
  if (themeColorBackup === null) {
363
547
  return;
364
548
  }
@@ -370,8 +554,10 @@
370
554
  themeColorBackup = null;
371
555
  for (const [index, root] of hostRoots.entries()) {
372
556
  root.style.backgroundColor = (_a2 = hostBackgroundColorBackup == null ? void 0 : hostBackgroundColorBackup[index]) != null ? _a2 : "";
557
+ root.style.overflow = (_b = hostOverflowBackup == null ? void 0 : hostOverflowBackup[index]) != null ? _b : "";
373
558
  }
374
559
  hostBackgroundColorBackup = null;
560
+ hostOverflowBackup = null;
375
561
  };
376
562
  const syncHostChrome = () => {
377
563
  if (elements.chatDialog.dataset.state !== "open") {
@@ -421,19 +607,21 @@
421
607
  stopViewportTracking();
422
608
  };
423
609
  const openChat = () => {
610
+ dismissPrompt();
424
611
  globalThis.clearTimeout(chatCloseTimer);
425
612
  chatCloseTimer = void 0;
426
613
  elements.chatDialog.hidden = false;
427
614
  elements.chatDialog.dataset.state = "open";
428
615
  elements.chatDialog.removeAttribute("aria-hidden");
429
616
  elements.chatDialog.removeAttribute("inert");
430
- setHasUnread(false);
617
+ syncLauncherLabel();
431
618
  startViewportTracking();
432
619
  ensureChatLoaded();
433
620
  elements.chatFrame.focus();
434
621
  writeOpenState(true);
435
622
  if (chatHasLoaded) {
436
623
  postHostReady();
624
+ focusChatComposer();
437
625
  }
438
626
  };
439
627
  const closeChat = () => {
@@ -469,12 +657,19 @@
469
657
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.CHILD_READY)) {
470
658
  revealChat();
471
659
  postHostReady();
660
+ if (elements.chatDialog.dataset.state === "open") {
661
+ focusChatComposer();
662
+ }
472
663
  return;
473
664
  }
474
665
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.AUTH_TOKEN_REQUEST)) {
475
666
  void answerAuthTokenRequest();
476
667
  return;
477
668
  }
669
+ if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.GET_ATTRIBUTION)) {
670
+ postToChat(createEmbedProtocolMessage(EMBED_MESSAGE_TYPE.ATTRIBUTION, { attribution: buildHostAttribution() }));
671
+ return;
672
+ }
478
673
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.CARET_STATE)) {
479
674
  if (message.payload.isFocused === true) {
480
675
  hasReleasedKeyboardEarly = false;
@@ -484,9 +679,6 @@
484
679
  return;
485
680
  }
486
681
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.MESSAGE_RECEIVED)) {
487
- if (elements.chatDialog.dataset.state !== "open") {
488
- setHasUnread(true);
489
- }
490
682
  return;
491
683
  }
492
684
  if (isEmbedProtocolMessage(message, EMBED_MESSAGE_TYPE.SIGNING_REQUESTED)) {
@@ -499,9 +691,24 @@
499
691
  }
500
692
  };
501
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
+ }
502
705
  if (event.key !== "Escape") {
503
706
  return;
504
707
  }
708
+ if (!elements.promptBackdrop.hidden) {
709
+ dismissPrompt(true);
710
+ return;
711
+ }
505
712
  if (elements.chatDialog.dataset.state === "open") {
506
713
  closeChat();
507
714
  }
@@ -510,6 +717,7 @@
510
717
  stopViewportTracking();
511
718
  globalThis.clearTimeout(chatCloseTimer);
512
719
  globalThis.clearTimeout(chatReadyTimer);
720
+ globalThis.clearTimeout(promptTimer);
513
721
  globalThis.removeEventListener("message", handleMessage);
514
722
  document.removeEventListener("keydown", handleKeydown);
515
723
  mount.remove();
@@ -522,6 +730,13 @@
522
730
  openChat();
523
731
  }
524
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
+ });
525
740
  elements.chatDialog.addEventListener("animationend", (event) => {
526
741
  if (event.target === elements.chatDialog) {
527
742
  finishChatClose();
@@ -535,14 +750,17 @@
535
750
  });
536
751
  globalThis.addEventListener("message", handleMessage);
537
752
  document.addEventListener("keydown", handleKeydown);
538
- setHasUnread(hasUnread);
539
- if (completedSigningEnvelopeId !== null || readOpenState()) {
753
+ syncLauncherLabel();
754
+ if (returnedFromSigning || readOpenState()) {
540
755
  openChat();
756
+ } else if (!hasPromptCooldown()) {
757
+ promptTimer = globalThis.setTimeout(showPrompt, PROMPT_DELAY_MS);
541
758
  }
542
759
  return Object.freeze({
543
760
  close: closeChat,
544
761
  destroy,
545
762
  open: openChat,
763
+ showPrompt,
546
764
  version: EMBED_PROTOCOL_VERSION
547
765
  });
548
766
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morgan-and-morgan/intake-agent",
3
- "version": "0.1.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"
@@ -18,20 +19,22 @@
18
19
  "publishConfig": {
19
20
  "access": "public"
20
21
  },
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
22
  "scripts": {
30
23
  "build": "pnpm run build:bundle",
31
24
  "build:bundle": "node scripts/generate-icons.mjs && vite build",
32
25
  "generate:icons": "node scripts/generate-icons.mjs",
26
+ "test": "node --test 'src/*.test.mjs'",
33
27
  "typecheck": "tsc --noEmit",
34
28
  "lint": "eslint \"src/**/*.ts\" --quiet",
35
- "lint:fix": "eslint \"src/**/*.ts\" --quiet --fix"
29
+ "lint:fix": "eslint \"src/**/*.ts\" --quiet --fix",
30
+ "prepublishOnly": "pnpm run build:bundle"
31
+ },
32
+ "devDependencies": {
33
+ "@mmcp/eslint-config": "workspace:*",
34
+ "@mmcp/tsconfig": "workspace:*",
35
+ "eslint": "^9.39.1",
36
+ "lucide-react-native": "^0.562.0",
37
+ "typescript": "~5.9.3",
38
+ "vite": "^7.3.6"
36
39
  }
37
- }
40
+ }