@getnexorai/sdk 0.1.1 → 0.1.3

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/dist/chat.cjs CHANGED
@@ -6,15 +6,26 @@ function applyDefaults(o) {
6
6
  workflowId: o.workflowId,
7
7
  title: o.title ?? "Chat with us",
8
8
  subtitle: o.subtitle ?? "We typically reply in a few minutes",
9
+ openingMessage: o.openingMessage,
9
10
  greeting: o.greeting ?? "Hi there! \u{1F44B} How can we help?",
10
11
  errorText: o.errorText ?? "Sorry, something went wrong. Please try again in a moment.",
12
+ disabledText: o.disabledText ?? "Chat isn't available right now. Please reach us on another channel.",
11
13
  locale: o.locale ?? (typeof navigator !== "undefined" ? navigator.language : void 0),
12
14
  accentColor: o.accentColor ?? "#111827",
13
15
  accentTextColor: o.accentTextColor ?? "#ffffff",
14
16
  showBranding: o.showBranding ?? true,
17
+ inputPlaceholder: o.inputPlaceholder ?? "Type a message\u2026",
18
+ showRestart: o.showRestart ?? true,
19
+ restartLabel: o.restartLabel ?? "Start over",
15
20
  position: o.position ?? "bottom-right",
16
21
  openOnLoad: o.openOnLoad ?? false,
22
+ channels: resolveChannels(o.channels),
23
+ requestContact: resolveRequestContact(o.channels?.requestContact),
17
24
  debounceMs: o.debounceMs ?? 700,
25
+ splitReplies: o.splitReplies ?? true,
26
+ splitDelayMs: o.splitDelayMs ?? 0,
27
+ // 0 = auto (random 1000–1500ms per bubble)
28
+ requestTimeoutMs: o.requestTimeoutMs ?? 6e4,
18
29
  container: o.container,
19
30
  systemPrompt: o.systemPrompt,
20
31
  clientPrompt: o.clientPrompt,
@@ -22,10 +33,22 @@ function applyDefaults(o) {
22
33
  capture: {
23
34
  fields: o.capture?.fields ?? ["first_name", "email"],
24
35
  mode: o.capture?.mode ?? "before",
36
+ createLeadOnSubmit: o.capture?.createLeadOnSubmit ?? false,
25
37
  label: o.capture?.label ?? "Tell us a bit about you to get started:",
26
- submitLabel: o.capture?.submitLabel ?? "Start chat"
38
+ submitLabel: o.capture?.submitLabel ?? "Start chat",
39
+ placeholders: o.capture?.placeholders ?? {},
40
+ phoneCountryCode: o.capture?.phoneCountryCode ?? "",
41
+ phoneCountries: o.capture?.phoneCountries ?? [],
42
+ errorMessages: {
43
+ required: o.capture?.errorMessages?.required ?? "Please complete this field.",
44
+ email: o.capture?.errorMessages?.email ?? "Enter a valid email address.",
45
+ phone: o.capture?.errorMessages?.phone ?? "Enter a valid phone number, including the country code."
46
+ }
27
47
  },
28
48
  metadata: o.metadata,
49
+ resumeText: o.resumeText ?? "Continuing your conversation",
50
+ resumeWithNameText: o.resumeWithNameText ?? "Continuing as {name}",
51
+ startOverText: o.startOverText ?? "Start over",
29
52
  onOpen: o.onOpen,
30
53
  onClose: o.onClose,
31
54
  onMessage: o.onMessage,
@@ -33,6 +56,126 @@ function applyDefaults(o) {
33
56
  onError: o.onError
34
57
  };
35
58
  }
59
+ var DEFAULT_CHANNEL_ORDER = [
60
+ "whatsapp",
61
+ "call",
62
+ "email",
63
+ "instagram",
64
+ "chat"
65
+ ];
66
+ var DEFAULT_CHANNEL_LABELS = {
67
+ whatsapp: "WhatsApp",
68
+ call: "Call",
69
+ email: "Email",
70
+ instagram: "Instagram",
71
+ chat: "Chat"
72
+ };
73
+ function resolveChannels(c) {
74
+ if (!c) return [];
75
+ const order = c.order ?? DEFAULT_CHANNEL_ORDER;
76
+ const label = (k) => c.labels?.[k] ?? DEFAULT_CHANNEL_LABELS[k];
77
+ const contactChannels = new Set(
78
+ c.requestContact ? c.requestContact.channels ?? ["call", "email"] : []
79
+ );
80
+ const contactBtn = (key) => ({
81
+ key,
82
+ href: null,
83
+ external: false,
84
+ action: "contact",
85
+ label: label(key)
86
+ });
87
+ const seen = /* @__PURE__ */ new Set();
88
+ const out = [];
89
+ for (const key of order) {
90
+ if (seen.has(key)) continue;
91
+ seen.add(key);
92
+ switch (key) {
93
+ case "whatsapp": {
94
+ if (contactChannels.has("whatsapp")) {
95
+ out.push(contactBtn(key));
96
+ break;
97
+ }
98
+ if (!c.whatsapp) break;
99
+ const digits = c.whatsapp.replace(/[^\d]/g, "");
100
+ if (!digits) break;
101
+ const qs = c.whatsappText ? `?text=${encodeURIComponent(c.whatsappText)}` : "";
102
+ out.push({ key, href: `https://wa.me/${digits}${qs}`, external: true, label: label(key) });
103
+ break;
104
+ }
105
+ case "call": {
106
+ if (contactChannels.has("call")) {
107
+ out.push(contactBtn(key));
108
+ break;
109
+ }
110
+ if (!c.call) break;
111
+ const tel = c.call.replace(/[^\d+]/g, "");
112
+ if (!tel) break;
113
+ out.push({ key, href: `tel:${tel}`, external: false, label: label(key) });
114
+ break;
115
+ }
116
+ case "email": {
117
+ if (contactChannels.has("email")) {
118
+ out.push(contactBtn(key));
119
+ break;
120
+ }
121
+ if (!c.email) break;
122
+ out.push({ key, href: `mailto:${c.email}`, external: false, label: label(key) });
123
+ break;
124
+ }
125
+ case "instagram": {
126
+ if (!c.instagram) break;
127
+ const href = /^https?:\/\//i.test(c.instagram) ? c.instagram : `https://instagram.com/${c.instagram.replace(/^@/, "")}`;
128
+ out.push({ key, href, external: true, label: label(key) });
129
+ break;
130
+ }
131
+ case "chat": {
132
+ if (c.chat === false) break;
133
+ out.push({ key, href: null, external: false, label: label(key) });
134
+ break;
135
+ }
136
+ }
137
+ }
138
+ if (out.every((ch) => ch.key === "chat")) return [];
139
+ return out;
140
+ }
141
+ var DEFAULT_CONTACT_TITLE = {
142
+ call: "Request a call",
143
+ email: "Request an email",
144
+ whatsapp: "Request a message"
145
+ };
146
+ var DEFAULT_CONTACT_SUBTITLE = {
147
+ call: "Leave your details and we'll call you. We need this to reach you.",
148
+ email: "Leave your details and we'll email you. We need this to reach you.",
149
+ whatsapp: "Leave your details and we'll message you. We need this to reach you."
150
+ };
151
+ var DEFAULT_CONTACT_SUBMIT = {
152
+ call: "Request call",
153
+ email: "Request email",
154
+ whatsapp: "Request message"
155
+ };
156
+ var DEFAULT_CONTACT_SUCCESS = {
157
+ call: "Thanks! We'll call you shortly.",
158
+ email: "Thanks! We'll email you shortly.",
159
+ whatsapp: "Thanks! We'll message you shortly."
160
+ };
161
+ function resolveRequestContact(rc) {
162
+ if (!rc) return void 0;
163
+ const channels = rc.channels && rc.channels.length ? rc.channels : ["call", "email"];
164
+ const pick = (over, def) => {
165
+ const out = {};
166
+ for (const ch of channels) out[ch] = over?.[ch] ?? def[ch];
167
+ return out;
168
+ };
169
+ return {
170
+ channels,
171
+ title: pick(rc.title, DEFAULT_CONTACT_TITLE),
172
+ subtitle: pick(rc.subtitle, DEFAULT_CONTACT_SUBTITLE),
173
+ submitLabel: pick(rc.submitLabel, DEFAULT_CONTACT_SUBMIT),
174
+ successText: pick(rc.successText, DEFAULT_CONTACT_SUCCESS),
175
+ doneLabel: rc.doneLabel ?? "Done",
176
+ errorText: rc.errorText ?? "Something went wrong. Please try again."
177
+ };
178
+ }
36
179
  function needsCapture(cfg) {
37
180
  if (cfg.capture.mode === "skip") return false;
38
181
  if (cfg.lead) {
@@ -93,6 +236,95 @@ function randomHex(bytes) {
93
236
  return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
94
237
  }
95
238
 
239
+ // src/chat/validate.ts
240
+ function isValidEmail(value) {
241
+ const s = (value || "").trim();
242
+ if (s.length < 6 || s.length > 254) return false;
243
+ return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s);
244
+ }
245
+
246
+ // src/chat/template.ts
247
+ var ALIASES = {
248
+ first_name: "first_name",
249
+ name: "first_name",
250
+ lead_first_name: "first_name",
251
+ last_name: "last_name",
252
+ lastname: "last_name",
253
+ lead_last_name: "last_name",
254
+ full_name: "full_name",
255
+ fullname: "full_name",
256
+ email: "email",
257
+ phone: "phone"
258
+ };
259
+ function clean(v) {
260
+ return v == null ? "" : String(v).trim();
261
+ }
262
+ function leadValue(lead, canonical) {
263
+ if (!lead) return "";
264
+ switch (canonical) {
265
+ case "first_name":
266
+ return clean(lead.first_name);
267
+ case "last_name":
268
+ return clean(lead.last_name);
269
+ case "full_name":
270
+ return [clean(lead.first_name), clean(lead.last_name)].filter(Boolean).join(" ");
271
+ case "email":
272
+ return clean(lead.email);
273
+ case "phone":
274
+ return clean(lead.phone);
275
+ default:
276
+ return "";
277
+ }
278
+ }
279
+ function renderLeadTemplate(template, lead) {
280
+ if (typeof template !== "string" || !template) return "";
281
+ const replaced = template.replace(/\{\{\s*([a-zA-Z_]+)\s*\}\}/g, (match, raw) => {
282
+ const canonical = ALIASES[raw.toLowerCase()];
283
+ if (!canonical) return match;
284
+ return leadValue(lead, canonical);
285
+ });
286
+ return replaced.replace(/[ \t]+([,.!?;:])/g, "$1").replace(/[ \t]{2,}/g, " ").replace(/^[ \t]*[,;:][ \t]*/gm, "").replace(/[ \t]+$/gm, "").trim();
287
+ }
288
+
289
+ // src/chat/countries.ts
290
+ var FALLBACK_COUNTRY = {
291
+ iso: "CL",
292
+ name: "Chile",
293
+ dial: "+56",
294
+ flag: "\u{1F1E8}\u{1F1F1}",
295
+ nsn: [9, 9]
296
+ };
297
+ var COUNTRIES = [
298
+ FALLBACK_COUNTRY,
299
+ { iso: "MX", name: "M\xE9xico", dial: "+52", flag: "\u{1F1F2}\u{1F1FD}", nsn: [10, 10] },
300
+ { iso: "AR", name: "Argentina", dial: "+54", flag: "\u{1F1E6}\u{1F1F7}", nsn: [10, 11] },
301
+ { iso: "CO", name: "Colombia", dial: "+57", flag: "\u{1F1E8}\u{1F1F4}", nsn: [10, 10] },
302
+ { iso: "PE", name: "Per\xFA", dial: "+51", flag: "\u{1F1F5}\u{1F1EA}", nsn: [9, 9] },
303
+ { iso: "BR", name: "Brasil", dial: "+55", flag: "\u{1F1E7}\u{1F1F7}", nsn: [10, 11] },
304
+ { iso: "EC", name: "Ecuador", dial: "+593", flag: "\u{1F1EA}\u{1F1E8}", nsn: [8, 9] },
305
+ { iso: "UY", name: "Uruguay", dial: "+598", flag: "\u{1F1FA}\u{1F1FE}", nsn: [8, 8] },
306
+ { iso: "US", name: "United States", dial: "+1", flag: "\u{1F1FA}\u{1F1F8}", nsn: [10, 10] },
307
+ { iso: "ES", name: "Espa\xF1a", dial: "+34", flag: "\u{1F1EA}\u{1F1F8}", nsn: [9, 9] },
308
+ { iso: "GB", name: "United Kingdom", dial: "+44", flag: "\u{1F1EC}\u{1F1E7}", nsn: [10, 10] }
309
+ ];
310
+ function maxNsn(c) {
311
+ return c.nsn[1];
312
+ }
313
+ function countryByIso(iso) {
314
+ if (!iso) return void 0;
315
+ const up = iso.toUpperCase();
316
+ return COUNTRIES.find((c) => c.iso === up);
317
+ }
318
+ function countryByDial(dial) {
319
+ if (!dial) return void 0;
320
+ const norm = dial.startsWith("+") ? dial : `+${dial.replace(/^\+?/, "")}`;
321
+ return COUNTRIES.find((c) => c.dial === norm);
322
+ }
323
+ function resolveDefaultCountry(codeOrIso) {
324
+ const v = (codeOrIso || "").trim();
325
+ return countryByDial(v) || countryByIso(v) || FALLBACK_COUNTRY;
326
+ }
327
+
96
328
  // src/chat/styles.ts
97
329
  var widgetCss = (accent, accentText) => `
98
330
  .nexor-chat,
@@ -116,6 +348,12 @@ var widgetCss = (accent, accentText) => `
116
348
  left: 20px;
117
349
  }
118
350
 
351
+ /* \u2500\u2500 Launcher wrapper (anchors the optional speed-dial fan) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
352
+ .nexor-chat__launcher-wrap {
353
+ position: relative;
354
+ display: inline-flex;
355
+ }
356
+
119
357
  /* \u2500\u2500 Launcher bubble \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
120
358
  .nexor-chat__launcher {
121
359
  width: 56px;
@@ -152,6 +390,12 @@ var widgetCss = (accent, accentText) => `
152
390
  transform: scale(0.85);
153
391
  opacity: 0.75;
154
392
  }
393
+ /* While the chat panel is open the launcher is dormant: no hover fan, no
394
+ clicks. The visitor closes the chat (header \u2715) to reach the channels again.
395
+ pointer-events:none also stops the CSS :hover that would reveal the fan. */
396
+ .nexor-chat[data-open="true"] .nexor-chat__launcher-wrap {
397
+ pointer-events: none;
398
+ }
155
399
  .nexor-chat__launcher svg { width: 24px; height: 24px; transition: transform 200ms ease; }
156
400
  .nexor-chat[data-open="true"] .nexor-chat__launcher svg { transform: rotate(8deg); }
157
401
 
@@ -183,6 +427,118 @@ var widgetCss = (accent, accentText) => `
183
427
  to { transform: scale(1); }
184
428
  }
185
429
 
430
+ /* \u2500\u2500 Speed-dial channel fan \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
431
+ /* Sits above the launcher, out of normal flow so it never shifts the bubble.
432
+ Hidden until the wrapper is hovered (pointer devices) or tapped open
433
+ (data-expanded, set by JS \u2014 the only path on touch). */
434
+ .nexor-chat__channels {
435
+ position: absolute;
436
+ /* Anchor the fan's box to the top of the launcher and push the buttons up
437
+ with padding instead of a gap. This keeps the launcher + fan a single,
438
+ continuous hover surface \u2014 moving the cursor from the bubble up to the
439
+ buttons never crosses dead space, so the drawer no longer snaps shut. */
440
+ bottom: 100%;
441
+ right: 0;
442
+ padding-bottom: 14px;
443
+ display: flex;
444
+ flex-direction: column;
445
+ gap: 12px;
446
+ align-items: flex-end;
447
+ opacity: 0;
448
+ visibility: hidden;
449
+ pointer-events: none;
450
+ transform: translateY(10px);
451
+ transition: opacity 180ms ease, transform 180ms ease, visibility 180ms;
452
+ }
453
+ .nexor-chat[data-position="bottom-left"] .nexor-chat__channels {
454
+ right: auto;
455
+ left: 0;
456
+ align-items: flex-start;
457
+ }
458
+
459
+ @media (hover: hover) and (pointer: fine) {
460
+ .nexor-chat__launcher-wrap:hover .nexor-chat__channels {
461
+ opacity: 1;
462
+ visibility: visible;
463
+ pointer-events: auto;
464
+ transform: translateY(0);
465
+ }
466
+ }
467
+ .nexor-chat__launcher-wrap[data-expanded="true"] .nexor-chat__channels {
468
+ opacity: 1;
469
+ visibility: visible;
470
+ pointer-events: auto;
471
+ transform: translateY(0);
472
+ }
473
+
474
+ /* Subtle rise-and-stagger as each item appears (nearest the launcher first). */
475
+ .nexor-chat__channel-item {
476
+ display: flex;
477
+ align-items: center;
478
+ gap: 10px;
479
+ transform: translateY(8px);
480
+ opacity: 0;
481
+ transition: transform 200ms cubic-bezier(.2,.7,.2,1.1), opacity 200ms ease;
482
+ }
483
+ .nexor-chat[data-position="bottom-left"] .nexor-chat__channel-item {
484
+ flex-direction: row-reverse;
485
+ }
486
+ .nexor-chat__launcher-wrap[data-expanded="true"] .nexor-chat__channel-item,
487
+ .nexor-chat__launcher-wrap:hover .nexor-chat__channel-item {
488
+ transform: translateY(0);
489
+ opacity: 1;
490
+ }
491
+ .nexor-chat__channel-item:nth-last-child(1) { transition-delay: 0ms; }
492
+ .nexor-chat__channel-item:nth-last-child(2) { transition-delay: 40ms; }
493
+ .nexor-chat__channel-item:nth-last-child(3) { transition-delay: 80ms; }
494
+ .nexor-chat__channel-item:nth-last-child(4) { transition-delay: 120ms; }
495
+ .nexor-chat__channel-item:nth-last-child(5) { transition-delay: 160ms; }
496
+
497
+ /* The round mini-button for each channel. */
498
+ .nexor-chat__channel {
499
+ width: 46px;
500
+ height: 46px;
501
+ border-radius: 9999px;
502
+ border: none;
503
+ cursor: pointer;
504
+ display: flex;
505
+ align-items: center;
506
+ justify-content: center;
507
+ padding: 0;
508
+ background: ${accent};
509
+ color: ${accentText};
510
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
511
+ text-decoration: none;
512
+ transition: transform 140ms cubic-bezier(.2,.7,.2,1.1), box-shadow 140ms ease, filter 140ms ease;
513
+ }
514
+ .nexor-chat__channel:hover {
515
+ transform: scale(1.08);
516
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.26);
517
+ filter: brightness(1.05);
518
+ }
519
+ .nexor-chat__channel:active { transform: scale(0.94); }
520
+ .nexor-chat__channel:focus-visible { outline: 2px solid ${accent}; outline-offset: 2px; }
521
+ .nexor-chat__channel svg { width: 24px; height: 24px; display: block; }
522
+
523
+ /* Brand colours win over the accent where a channel has a recognisable hue. */
524
+ .nexor-chat__channel--whatsapp { background: #25d366; color: #fff; }
525
+ .nexor-chat__channel--instagram {
526
+ background: radial-gradient(circle at 30% 110%, #fdf497 0%, #fd5949 45%, #d6249f 70%, #285aeb 100%);
527
+ color: #fff;
528
+ }
529
+
530
+ /* Label pill beside each button. */
531
+ .nexor-chat__channel-label {
532
+ background: #fff;
533
+ color: #111;
534
+ font-size: 12px;
535
+ font-weight: 600;
536
+ padding: 5px 10px;
537
+ border-radius: 8px;
538
+ white-space: nowrap;
539
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
540
+ }
541
+
186
542
  /* \u2500\u2500 Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
187
543
  .nexor-chat__panel {
188
544
  position: absolute;
@@ -236,6 +592,7 @@ var widgetCss = (accent, accentText) => `
236
592
  font-size: 12px;
237
593
  opacity: 0.85;
238
594
  margin: 2px 0 0;
595
+ line-height: 1.4;
239
596
  }
240
597
  .nexor-chat__status-dot {
241
598
  display: inline-block;
@@ -265,6 +622,23 @@ var widgetCss = (accent, accentText) => `
265
622
  .nexor-chat__close:hover { background: rgba(255,255,255,0.18); opacity: 1; }
266
623
  .nexor-chat__close:active { transform: scale(0.92); }
267
624
  .nexor-chat__close svg { width: 18px; height: 18px; display: block; }
625
+ /* Keep the title left and group the action buttons on the right. */
626
+ .nexor-chat__header > div { margin-right: auto; min-width: 0; }
627
+ .nexor-chat__restart {
628
+ background: transparent;
629
+ border: none;
630
+ color: inherit;
631
+ cursor: pointer;
632
+ padding: 4px;
633
+ border-radius: 6px;
634
+ opacity: 0.7;
635
+ transition: background 140ms ease, transform 200ms ease, opacity 140ms ease;
636
+ }
637
+ .nexor-chat__restart:hover { background: rgba(255,255,255,0.18); opacity: 1; }
638
+ .nexor-chat__restart:active { transform: rotate(-90deg) scale(0.9); }
639
+ .nexor-chat__restart svg { width: 17px; height: 17px; display: block; }
640
+ /* Nothing to reset on the capture screen \u2014 hide it there. */
641
+ .nexor-chat__panel[data-view="capture"] .nexor-chat__restart { display: none; }
268
642
 
269
643
  /* \u2500\u2500 Body / messages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
270
644
  .nexor-chat__body {
@@ -364,6 +738,72 @@ var widgetCss = (accent, accentText) => `
364
738
  30% { opacity: 1; transform: translateY(-3px); }
365
739
  }
366
740
 
741
+ /* \u2500\u2500 Resume banner (returning visitor) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
742
+ .nexor-chat__resume {
743
+ display: flex;
744
+ align-items: center;
745
+ justify-content: space-between;
746
+ gap: 10px;
747
+ background: #fff;
748
+ border: 1px solid rgba(0,0,0,0.06);
749
+ border-radius: 10px;
750
+ padding: 8px 12px;
751
+ margin-bottom: 4px;
752
+ box-shadow: 0 1px 2px rgba(0,0,0,0.04);
753
+ animation: nexor-fade-in 240ms ease both;
754
+ }
755
+ .nexor-chat__resume-text {
756
+ font-size: 12px;
757
+ color: #6b7280;
758
+ min-width: 0;
759
+ overflow: hidden;
760
+ text-overflow: ellipsis;
761
+ white-space: nowrap;
762
+ }
763
+ .nexor-chat__resume-reset {
764
+ flex: none;
765
+ background: transparent;
766
+ border: none;
767
+ color: ${accent};
768
+ font: inherit;
769
+ font-size: 12px;
770
+ font-weight: 600;
771
+ cursor: pointer;
772
+ padding: 2px 4px;
773
+ border-radius: 6px;
774
+ text-decoration: underline;
775
+ text-underline-offset: 2px;
776
+ transition: opacity 140ms ease;
777
+ }
778
+ .nexor-chat__resume-reset:hover { opacity: 0.7; }
779
+ .nexor-chat__resume-reset:active { opacity: 0.5; }
780
+
781
+ /* \u2500\u2500 Capture as its own full-panel view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
782
+ /* The form is a distinct screen, not a card wedged into the message list.
783
+ data-view swaps between the capture screen and the chat (body + composer). */
784
+ .nexor-chat__capture-view {
785
+ min-height: 0;
786
+ overflow-y: auto;
787
+ padding: 16px;
788
+ background: #f6f7f9;
789
+ display: flex;
790
+ flex-direction: column;
791
+ animation: nexor-fade-in 200ms ease both;
792
+ }
793
+ /* In the form view the panel hugs the form \u2014 no fixed 540px height leaving
794
+ empty gaps above and below the card. It still can't exceed max-height. */
795
+ .nexor-chat__panel[data-view="capture"] {
796
+ height: auto;
797
+ }
798
+ .nexor-chat__panel[data-view="chat"] .nexor-chat__capture-view { display: none; }
799
+ .nexor-chat__panel[data-view="capture"] .nexor-chat__body,
800
+ .nexor-chat__panel[data-view="capture"] .nexor-chat__composer { display: none; }
801
+ .nexor-chat__capture-view .nexor-chat__capture-label {
802
+ font-size: 13px;
803
+ font-weight: 600;
804
+ color: #374151;
805
+ }
806
+
367
807
  /* \u2500\u2500 Lead capture form \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
368
808
  .nexor-chat__capture {
369
809
  background: #fff;
@@ -399,11 +839,72 @@ var widgetCss = (accent, accentText) => `
399
839
  width: 100%;
400
840
  transition: border-color 140ms ease, box-shadow 140ms ease;
401
841
  }
842
+ /* Composer textarea: auto-grows (height managed in JS) up to ~4 lines, then
843
+ scrolls. resize:none disables the manual drag handle. */
844
+ textarea.nexor-chat__input {
845
+ display: block;
846
+ resize: none;
847
+ overflow-y: auto;
848
+ line-height: 20px;
849
+ max-height: 98px; /* 4 lines (20\xD74) + 16 padding + 2 border */
850
+ white-space: pre-wrap;
851
+ word-break: break-word;
852
+ }
402
853
  .nexor-chat__input:focus,
403
854
  .nexor-chat__capture input:focus {
404
855
  border-color: ${accent};
405
856
  box-shadow: 0 0 0 3px ${accent}33;
406
857
  }
858
+ .nexor-chat__input--error,
859
+ .nexor-chat__capture input.nexor-chat__input--error {
860
+ border-color: #ef4444;
861
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.16);
862
+ }
863
+ .nexor-chat__capture-error {
864
+ font-size: 12px;
865
+ color: #ef4444;
866
+ margin: 0;
867
+ }
868
+ .nexor-chat__capture-error[hidden] { display: none; }
869
+
870
+ /* \u2500\u2500 Phone field (flag + dial code selector joined to a digit input) \u2500\u2500 */
871
+ .nexor-chat__phone {
872
+ display: flex;
873
+ align-items: stretch;
874
+ width: 100%;
875
+ border: 1px solid #d1d5db;
876
+ border-radius: 8px;
877
+ background: #fff;
878
+ overflow: hidden;
879
+ transition: border-color 140ms ease, box-shadow 140ms ease;
880
+ }
881
+ .nexor-chat__phone:focus-within {
882
+ border-color: ${accent};
883
+ box-shadow: 0 0 0 3px ${accent}33;
884
+ }
885
+ .nexor-chat__phone.nexor-chat__input--error {
886
+ border-color: #ef4444;
887
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.16);
888
+ }
889
+ .nexor-chat__phone-country {
890
+ flex: none;
891
+ border: none;
892
+ border-right: 1px solid #e5e7eb;
893
+ background: #f9fafb;
894
+ color: #111;
895
+ font: inherit;
896
+ padding: 0 12px 0 10px;
897
+ cursor: pointer;
898
+ outline: none;
899
+ }
900
+ .nexor-chat__phone-number.nexor-chat__input {
901
+ border: none;
902
+ border-radius: 0;
903
+ box-shadow: none;
904
+ flex: 1;
905
+ min-width: 0;
906
+ }
907
+
407
908
  .nexor-chat__capture-submit {
408
909
  background: ${accent};
409
910
  color: ${accentText};
@@ -419,6 +920,66 @@ var widgetCss = (accent, accentText) => `
419
920
  .nexor-chat__capture-submit:active { transform: scale(0.97); }
420
921
  .nexor-chat__capture-submit[disabled] { opacity: 0.6; cursor: not-allowed; }
421
922
 
923
+ /* \u2500\u2500 Contact-request view (call me / email me) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
924
+ /* Its own full-panel screen, opened from a "call"/"email" fan button. Reuses
925
+ the capture form styling (.nexor-chat__capture) for the fields. */
926
+ .nexor-chat__contact-view {
927
+ min-height: 0;
928
+ overflow-y: auto;
929
+ padding: 16px;
930
+ background: #f6f7f9;
931
+ display: flex;
932
+ flex-direction: column;
933
+ animation: nexor-fade-in 200ms ease both;
934
+ }
935
+ /* Show the contact view only in its own data-view; hug the form (no fixed
936
+ height leaving gaps), but never exceed the panel's max-height. */
937
+ .nexor-chat__panel[data-view="contact"] { height: auto; }
938
+ .nexor-chat__panel:not([data-view="contact"]) .nexor-chat__contact-view { display: none; }
939
+ .nexor-chat__panel[data-view="contact"] .nexor-chat__capture-view,
940
+ .nexor-chat__panel[data-view="contact"] .nexor-chat__body,
941
+ .nexor-chat__panel[data-view="contact"] .nexor-chat__composer { display: none; }
942
+ .nexor-chat__panel[data-view="contact"] .nexor-chat__restart { display: none; }
943
+
944
+ /* The contact form lives under the header, which carries its title/explainer;
945
+ the live-status dot ("we're listening") makes no sense for a form. */
946
+ .nexor-chat__panel[data-view="contact"] .nexor-chat__status-dot { display: none; }
947
+
948
+ /* form \u2194 success swap, driven by the view's data-state */
949
+ .nexor-chat__contact-view[data-state="sent"] .nexor-chat__contact-form { display: none; }
950
+ .nexor-chat__contact-view:not([data-state="sent"]) .nexor-chat__contact-success { display: none; }
951
+
952
+ .nexor-chat__contact-success {
953
+ display: flex;
954
+ flex-direction: column;
955
+ align-items: center;
956
+ text-align: center;
957
+ gap: 12px;
958
+ padding: 28px 12px 12px;
959
+ animation: nexor-msg-in 280ms cubic-bezier(.2,.7,.2,1.1) both;
960
+ }
961
+ .nexor-chat__contact-success-icon {
962
+ width: 48px;
963
+ height: 48px;
964
+ display: flex;
965
+ align-items: center;
966
+ justify-content: center;
967
+ border-radius: 50%;
968
+ background: ${accent};
969
+ color: ${accentText};
970
+ }
971
+ .nexor-chat__contact-success-icon svg { width: 26px; height: 26px; }
972
+ .nexor-chat__contact-success-text {
973
+ font-size: 14px;
974
+ color: #374151;
975
+ margin: 0;
976
+ line-height: 1.5;
977
+ }
978
+ .nexor-chat__contact-success .nexor-chat__capture-submit {
979
+ margin-top: 4px;
980
+ align-self: stretch;
981
+ }
982
+
422
983
  /* \u2500\u2500 Composer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
423
984
  .nexor-chat__composer {
424
985
  display: flex;
@@ -426,7 +987,8 @@ var widgetCss = (accent, accentText) => `
426
987
  padding: 10px;
427
988
  background: #fff;
428
989
  border-top: 1px solid rgba(0,0,0,0.06);
429
- align-items: stretch;
990
+ /* Pin the send button to the bottom so it stays put as the textarea grows. */
991
+ align-items: flex-end;
430
992
  }
431
993
  .nexor-chat__send {
432
994
  background: ${accent};
@@ -438,6 +1000,8 @@ var widgetCss = (accent, accentText) => `
438
1000
  font-weight: 600;
439
1001
  cursor: pointer;
440
1002
  min-width: 44px;
1003
+ height: 38px; /* matches the one-line textarea height */
1004
+ flex: none;
441
1005
  display: flex;
442
1006
  align-items: center;
443
1007
  justify-content: center;
@@ -473,7 +1037,10 @@ var widgetCss = (accent, accentText) => `
473
1037
  }
474
1038
 
475
1039
  .nexor-chat__footer {
476
- text-align: center;
1040
+ display: flex;
1041
+ align-items: center;
1042
+ justify-content: center;
1043
+ gap: 5px;
477
1044
  font-size: 11px;
478
1045
  color: #9ca3af;
479
1046
  padding: 6px;
@@ -485,7 +1052,111 @@ var widgetCss = (accent, accentText) => `
485
1052
  text-decoration: none;
486
1053
  font-weight: 600;
487
1054
  }
488
- .nexor-chat__footer a:hover { text-decoration: underline; }
1055
+ /* "Powered by" + the Nexor wordmark (same mark as the site navbar). Plain
1056
+ attribution \u2014 not a link, but hovering shows a getnexor.ai tooltip. */
1057
+ .nexor-chat__brand {
1058
+ position: relative;
1059
+ display: inline-flex;
1060
+ align-items: center;
1061
+ cursor: help; /* hover reveals the getnexor.ai tooltip; not a link */
1062
+ }
1063
+ .nexor-chat__brand-logo {
1064
+ height: 13px;
1065
+ width: auto;
1066
+ display: block;
1067
+ }
1068
+ /* Custom tooltip (instant + reliable across browsers, unlike a native SVG
1069
+ <title>). Sits above the logo and fades in on hover/focus. */
1070
+ .nexor-chat__brand-tip {
1071
+ position: absolute;
1072
+ bottom: calc(100% + 7px);
1073
+ left: 50%;
1074
+ transform: translateX(-50%) translateY(4px);
1075
+ background: #111827;
1076
+ color: #fff;
1077
+ font-size: 11px;
1078
+ font-weight: 500;
1079
+ line-height: 1;
1080
+ letter-spacing: 0.01em;
1081
+ padding: 5px 8px;
1082
+ border-radius: 6px;
1083
+ white-space: nowrap;
1084
+ opacity: 0;
1085
+ pointer-events: none;
1086
+ box-shadow: 0 4px 12px rgba(0,0,0,0.18);
1087
+ transition: opacity 140ms ease, transform 140ms ease;
1088
+ z-index: 5;
1089
+ }
1090
+ .nexor-chat__brand-tip::after {
1091
+ content: "";
1092
+ position: absolute;
1093
+ top: 100%;
1094
+ left: 50%;
1095
+ transform: translateX(-50%);
1096
+ border: 4px solid transparent;
1097
+ border-top-color: #111827;
1098
+ }
1099
+ .nexor-chat__brand:hover .nexor-chat__brand-tip {
1100
+ opacity: 1;
1101
+ transform: translateX(-50%) translateY(0);
1102
+ }
1103
+
1104
+ /* \u2500\u2500 Mobile: full-screen panel with an obvious close affordance \u2500\u2500\u2500\u2500\u2500 */
1105
+ @media (max-width: 480px) {
1106
+ /* The panel takes over the whole viewport so the keyboard + messages have
1107
+ room. 100dvh tracks the dynamic viewport as mobile browser chrome shows/
1108
+ hides, avoiding a cut-off composer. */
1109
+ .nexor-chat__panel {
1110
+ position: fixed;
1111
+ inset: 0;
1112
+ top: 0;
1113
+ left: 0;
1114
+ right: 0;
1115
+ bottom: 0;
1116
+ width: 100%;
1117
+ max-width: 100%;
1118
+ height: 100vh;
1119
+ height: 100dvh;
1120
+ max-height: none;
1121
+ border-radius: 0;
1122
+ border: none;
1123
+ transform: translateY(100%);
1124
+ }
1125
+ .nexor-chat[data-position="bottom-left"] .nexor-chat__panel { left: 0; }
1126
+ .nexor-chat[data-open="true"] .nexor-chat__panel { transform: translateY(0); }
1127
+ /* Keep the form view full-screen on phones too (overrides the desktop
1128
+ hug-the-form height). */
1129
+ .nexor-chat__panel[data-view="capture"] {
1130
+ height: 100vh;
1131
+ height: 100dvh;
1132
+ }
1133
+ .nexor-chat__panel[data-view="capture"] .nexor-chat__capture-view {
1134
+ justify-content: center;
1135
+ }
1136
+
1137
+ /* Hide the launcher (and any open fan) while the full-screen panel is up \u2014
1138
+ the in-header close button is the way back out. */
1139
+ .nexor-chat[data-open="true"] .nexor-chat__launcher-wrap {
1140
+ opacity: 0;
1141
+ pointer-events: none;
1142
+ }
1143
+
1144
+ /* Roomier header + a bigger, unmistakable close target on touch. */
1145
+ .nexor-chat__header {
1146
+ padding: 16px 14px 16px 18px;
1147
+ padding-top: max(16px, env(safe-area-inset-top));
1148
+ }
1149
+ .nexor-chat__close {
1150
+ padding: 10px;
1151
+ background: rgba(255, 255, 255, 0.16);
1152
+ }
1153
+ .nexor-chat__close svg { width: 24px; height: 24px; }
1154
+
1155
+ /* Keep the composer clear of the home-indicator / gesture bar. */
1156
+ .nexor-chat__composer {
1157
+ padding-bottom: max(10px, env(safe-area-inset-bottom));
1158
+ }
1159
+ }
489
1160
 
490
1161
  /* Respect users who request reduced motion. */
491
1162
  @media (prefers-reduced-motion: reduce) {
@@ -507,50 +1178,175 @@ function injectStyles(accent, accentText) {
507
1178
  style.textContent = widgetCss(accent, accentText);
508
1179
  document.head.appendChild(style);
509
1180
  }
510
- function buildLauncher() {
1181
+ var CHAT_ICON = `
1182
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1183
+ <path d="M4 5.5C4 4.67 4.67 4 5.5 4h13c.83 0 1.5.67 1.5 1.5v9c0 .83-.67 1.5-1.5 1.5H9l-4 4V5.5Z"
1184
+ stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
1185
+ </svg>`;
1186
+ var CHANNEL_ICONS = {
1187
+ whatsapp: `
1188
+ <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1189
+ <path d="M.057 24l1.687-6.163a11.867 11.867 0 0 1-1.587-5.946C.16 5.335 5.495 0 12.05 0a11.817 11.817 0 0 1 8.413 3.488 11.824 11.824 0 0 1 3.48 8.414c-.003 6.557-5.338 11.892-11.893 11.892a11.9 11.9 0 0 1-5.688-1.448L.057 24zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884a9.82 9.82 0 0 0 1.5 5.245l-.999 3.648 3.738-.981zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414z"/>
1190
+ </svg>`,
1191
+ call: `
1192
+ <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1193
+ <path d="M6.6 10.8c1.4 2.8 3.8 5.1 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.5.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.5 21 3 13.5 3 4.5c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.2.2 2.4.6 3.5.1.4 0 .8-.2 1L6.6 10.8Z"/>
1194
+ </svg>`,
1195
+ email: `
1196
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1197
+ <rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8"/>
1198
+ <path d="m4 7 8 6 8-6" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
1199
+ </svg>`,
1200
+ instagram: `
1201
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1202
+ <rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke="currentColor" stroke-width="1.8"/>
1203
+ <circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/>
1204
+ <circle cx="17.2" cy="6.8" r="1.2" fill="currentColor"/>
1205
+ </svg>`,
1206
+ chat: CHAT_ICON
1207
+ };
1208
+ function buildLauncher(cfg) {
1209
+ const wrap = document.createElement("div");
1210
+ wrap.className = "nexor-chat__launcher-wrap";
1211
+ const hasFan = cfg.channels.length > 0;
1212
+ const { fan, channelButtons } = hasFan ? buildChannelFan(cfg.channels) : { fan: null, channelButtons: [] };
1213
+ if (fan) wrap.appendChild(fan);
511
1214
  const el = document.createElement("button");
512
1215
  el.type = "button";
513
1216
  el.className = "nexor-chat__launcher";
514
- el.setAttribute("aria-label", "Open chat");
515
- el.innerHTML = `
516
- <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
517
- <path d="M4 5.5C4 4.67 4.67 4 5.5 4h13c.83 0 1.5.67 1.5 1.5v9c0 .83-.67 1.5-1.5 1.5H9l-4 4V5.5Z"
518
- stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
519
- </svg>
520
- `;
1217
+ el.setAttribute("aria-label", hasFan ? "Contact us" : "Open chat");
1218
+ if (hasFan) {
1219
+ el.setAttribute("aria-haspopup", "true");
1220
+ el.setAttribute("aria-expanded", "false");
1221
+ }
1222
+ el.innerHTML = CHAT_ICON;
521
1223
  const badge = document.createElement("span");
522
1224
  badge.className = "nexor-chat__unread";
523
1225
  badge.style.display = "none";
524
1226
  el.appendChild(badge);
525
- return { el, badge };
1227
+ wrap.appendChild(el);
1228
+ return { wrap, el, badge, fan, channelButtons };
1229
+ }
1230
+ function buildChannelFan(channels) {
1231
+ const fan = document.createElement("div");
1232
+ fan.className = "nexor-chat__channels";
1233
+ fan.setAttribute("role", "menu");
1234
+ const channelButtons = [];
1235
+ for (const ch of channels) {
1236
+ const item = document.createElement("div");
1237
+ item.className = "nexor-chat__channel-item";
1238
+ const labelEl = document.createElement("span");
1239
+ labelEl.className = "nexor-chat__channel-label";
1240
+ labelEl.textContent = ch.label;
1241
+ let el;
1242
+ if (ch.href === null) {
1243
+ const btn = document.createElement("button");
1244
+ btn.type = "button";
1245
+ el = btn;
1246
+ } else {
1247
+ const a = document.createElement("a");
1248
+ a.href = ch.href;
1249
+ if (ch.external) {
1250
+ a.target = "_blank";
1251
+ a.rel = "noopener noreferrer";
1252
+ }
1253
+ el = a;
1254
+ }
1255
+ el.className = `nexor-chat__channel nexor-chat__channel--${ch.key}`;
1256
+ el.setAttribute("role", "menuitem");
1257
+ el.setAttribute("aria-label", ch.label);
1258
+ el.innerHTML = CHANNEL_ICONS[ch.key];
1259
+ item.appendChild(labelEl);
1260
+ item.appendChild(el);
1261
+ fan.appendChild(item);
1262
+ channelButtons.push({ key: ch.key, el, action: ch.action });
1263
+ }
1264
+ return { fan, channelButtons };
526
1265
  }
527
1266
  function buildPanel(cfg) {
528
1267
  const el = document.createElement("div");
529
1268
  el.className = "nexor-chat__panel";
530
1269
  el.setAttribute("role", "dialog");
531
1270
  el.setAttribute("aria-label", cfg.title);
532
- const { header, closeBtn } = buildHeader(cfg);
533
- const { body, captureForm, captureInputs } = buildBody(cfg);
534
- const { form, input, send } = buildComposer();
1271
+ const { header, closeBtn, restartBtn, titleEl, subtitleEl, subtitleTextEl } = buildHeader(cfg);
1272
+ const body = buildBody();
1273
+ const { form, input, send } = buildComposer(cfg);
535
1274
  const footer = cfg.showBranding ? buildFooter() : null;
1275
+ const captureInputs = {};
1276
+ let captureView = null;
1277
+ let captureForm = null;
1278
+ let captureError = null;
1279
+ let phoneCountry = null;
1280
+ if (needsCapture(cfg)) {
1281
+ const built = buildCaptureView(cfg, captureInputs);
1282
+ captureView = built.view;
1283
+ captureForm = built.form;
1284
+ captureError = built.error;
1285
+ phoneCountry = built.phoneCountry;
1286
+ }
1287
+ const contact = cfg.requestContact ? buildContactView(cfg) : null;
1288
+ el.setAttribute("data-view", captureView ? "capture" : "chat");
536
1289
  el.appendChild(header);
1290
+ if (captureView) el.appendChild(captureView);
1291
+ if (contact) el.appendChild(contact.view);
537
1292
  el.appendChild(body);
538
1293
  el.appendChild(form);
539
1294
  if (footer) el.appendChild(footer);
540
- return { el, closeBtn, body, form, input, send, captureForm, captureInputs };
1295
+ return {
1296
+ el,
1297
+ closeBtn,
1298
+ restartBtn,
1299
+ headerTitle: titleEl,
1300
+ headerSubtitle: subtitleEl,
1301
+ headerSubtitleText: subtitleTextEl,
1302
+ body,
1303
+ form,
1304
+ input,
1305
+ send,
1306
+ captureView,
1307
+ captureForm,
1308
+ captureError,
1309
+ phoneCountry,
1310
+ captureInputs,
1311
+ contact
1312
+ };
541
1313
  }
542
1314
  function buildHeader(cfg) {
543
1315
  const header = document.createElement("div");
544
1316
  header.className = "nexor-chat__header";
545
- const subtitleHtml = cfg.subtitle ? `<p class="nexor-chat__subtitle">
546
- <span class="nexor-chat__status-dot" aria-hidden="true"></span>${escapeHtml(cfg.subtitle)}
547
- </p>` : "";
548
- header.innerHTML = `
549
- <div>
550
- <p class="nexor-chat__title">${escapeHtml(cfg.title)}</p>
551
- ${subtitleHtml}
552
- </div>
553
- `;
1317
+ const info = document.createElement("div");
1318
+ const titleEl = document.createElement("p");
1319
+ titleEl.className = "nexor-chat__title";
1320
+ titleEl.textContent = cfg.title;
1321
+ info.appendChild(titleEl);
1322
+ const subtitleEl = document.createElement("p");
1323
+ subtitleEl.className = "nexor-chat__subtitle";
1324
+ const dot = document.createElement("span");
1325
+ dot.className = "nexor-chat__status-dot";
1326
+ dot.setAttribute("aria-hidden", "true");
1327
+ const subtitleTextEl = document.createElement("span");
1328
+ subtitleTextEl.className = "nexor-chat__subtitle-text";
1329
+ subtitleTextEl.textContent = cfg.subtitle ?? "";
1330
+ subtitleEl.appendChild(dot);
1331
+ subtitleEl.appendChild(subtitleTextEl);
1332
+ if (!cfg.subtitle) subtitleEl.hidden = true;
1333
+ info.appendChild(subtitleEl);
1334
+ header.appendChild(info);
1335
+ let restartBtn = null;
1336
+ if (cfg.showRestart) {
1337
+ restartBtn = document.createElement("button");
1338
+ restartBtn.type = "button";
1339
+ restartBtn.className = "nexor-chat__restart";
1340
+ restartBtn.setAttribute("aria-label", cfg.restartLabel);
1341
+ restartBtn.title = cfg.restartLabel;
1342
+ restartBtn.innerHTML = `
1343
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1344
+ <path d="M4 5v5h5M20 19v-5h-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1345
+ <path d="M19 10a7 7 0 0 0-12-3L4 10M5 14a7 7 0 0 0 12 3l3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1346
+ </svg>
1347
+ `;
1348
+ header.appendChild(restartBtn);
1349
+ }
554
1350
  const closeBtn = document.createElement("button");
555
1351
  closeBtn.type = "button";
556
1352
  closeBtn.className = "nexor-chat__close";
@@ -561,64 +1357,182 @@ function buildHeader(cfg) {
561
1357
  </svg>
562
1358
  `;
563
1359
  header.appendChild(closeBtn);
564
- return { header, closeBtn };
1360
+ return { header, closeBtn, restartBtn, titleEl, subtitleEl, subtitleTextEl };
565
1361
  }
566
- function buildBody(cfg) {
1362
+ function buildBody() {
567
1363
  const body = document.createElement("div");
568
1364
  body.className = "nexor-chat__body";
569
- const captureInputs = {};
570
- let captureForm = null;
571
- if (needsCapture(cfg)) {
572
- captureForm = buildCaptureForm(cfg, captureInputs);
573
- body.appendChild(captureForm);
574
- }
575
- return { body, captureForm, captureInputs };
576
- }
1365
+ return body;
1366
+ }
1367
+ function buildCaptureView(cfg, inputsOut) {
1368
+ const view = document.createElement("div");
1369
+ view.className = "nexor-chat__capture-view";
1370
+ const { form, error, phoneCountry } = buildCaptureForm(cfg, inputsOut);
1371
+ view.appendChild(form);
1372
+ return { view, form, error, phoneCountry };
1373
+ }
1374
+ var DEFAULT_FIELD_PLACEHOLDERS = {
1375
+ first_name: "First name",
1376
+ last_name: "Last name",
1377
+ email: "Email",
1378
+ phone: "Phone"
1379
+ };
577
1380
  function buildCaptureForm(cfg, inputsOut) {
578
1381
  const form = document.createElement("form");
579
1382
  form.className = "nexor-chat__capture";
580
1383
  form.noValidate = true;
1384
+ let phoneCountry = null;
581
1385
  const label = document.createElement("p");
582
1386
  label.className = "nexor-chat__capture-label";
583
1387
  label.textContent = cfg.capture.label;
584
1388
  form.appendChild(label);
585
1389
  const fields = cfg.capture.fields;
1390
+ const ph = (f) => cfg.capture.placeholders[f] ?? DEFAULT_FIELD_PLACEHOLDERS[f];
586
1391
  if (fields.includes("first_name") || fields.includes("last_name")) {
587
1392
  const row = document.createElement("div");
588
1393
  row.className = "nexor-chat__capture-row";
589
1394
  if (fields.includes("first_name")) {
590
- inputsOut.first_name = makeInput("first_name", "First name", "text", cfg.lead?.first_name);
1395
+ inputsOut.first_name = makeInput("first_name", ph("first_name"), "text", cfg.lead?.first_name);
591
1396
  row.appendChild(inputsOut.first_name);
592
1397
  }
593
1398
  if (fields.includes("last_name")) {
594
- inputsOut.last_name = makeInput("last_name", "Last name", "text", cfg.lead?.last_name);
1399
+ inputsOut.last_name = makeInput("last_name", ph("last_name"), "text", cfg.lead?.last_name);
595
1400
  row.appendChild(inputsOut.last_name);
596
1401
  }
597
1402
  if (row.childElementCount > 0) form.appendChild(row);
598
1403
  }
599
1404
  if (fields.includes("email")) {
600
- inputsOut.email = makeInput("email", "Email", "email", cfg.lead?.email);
1405
+ inputsOut.email = makeInput("email", ph("email"), "email", cfg.lead?.email);
601
1406
  form.appendChild(inputsOut.email);
602
1407
  }
603
1408
  if (fields.includes("phone")) {
604
- inputsOut.phone = makeInput("phone", "Phone", "tel", cfg.lead?.phone);
605
- form.appendChild(inputsOut.phone);
1409
+ const built = buildPhoneField(cfg, ph("phone"));
1410
+ inputsOut.phone = built.input;
1411
+ phoneCountry = built.select;
1412
+ form.appendChild(built.wrap);
606
1413
  }
1414
+ const error = document.createElement("p");
1415
+ error.className = "nexor-chat__capture-error";
1416
+ error.setAttribute("role", "alert");
1417
+ error.hidden = true;
1418
+ form.appendChild(error);
607
1419
  const submit = document.createElement("button");
608
1420
  submit.type = "submit";
609
1421
  submit.className = "nexor-chat__capture-submit";
610
1422
  submit.textContent = cfg.capture.submitLabel;
611
1423
  form.appendChild(submit);
612
- return form;
1424
+ return { form, error, phoneCountry };
1425
+ }
1426
+ function resolveCountryList(isos) {
1427
+ if (!isos.length) return COUNTRIES;
1428
+ const picked = isos.map((i) => countryByIso(i)).filter((c) => Boolean(c));
1429
+ return picked.length ? picked : COUNTRIES;
1430
+ }
1431
+ function buildPhoneField(cfg, placeholder) {
1432
+ const wrap = document.createElement("div");
1433
+ wrap.className = "nexor-chat__phone";
1434
+ const list = resolveCountryList(cfg.capture.phoneCountries);
1435
+ const def = resolveDefaultCountry(cfg.capture.phoneCountryCode);
1436
+ const select = document.createElement("select");
1437
+ select.className = "nexor-chat__phone-country";
1438
+ select.name = "phone_country";
1439
+ select.setAttribute("aria-label", "Country code");
1440
+ for (const c of list) {
1441
+ const opt = document.createElement("option");
1442
+ opt.value = c.iso;
1443
+ opt.textContent = `${c.flag} ${c.dial}`;
1444
+ if (c.iso === def.iso) opt.selected = true;
1445
+ select.appendChild(opt);
1446
+ }
1447
+ const input = document.createElement("input");
1448
+ input.name = "phone";
1449
+ input.type = "tel";
1450
+ input.inputMode = "numeric";
1451
+ input.autocomplete = "tel-national";
1452
+ input.placeholder = placeholder;
1453
+ input.className = "nexor-chat__input nexor-chat__phone-number";
1454
+ input.maxLength = maxNsn(def);
1455
+ if (cfg.lead?.phone) {
1456
+ const digits = String(cfg.lead.phone).replace(/\D/g, "");
1457
+ const dialDigits = def.dial.replace(/\D/g, "");
1458
+ const national = digits.startsWith(dialDigits) ? digits.slice(dialDigits.length) : digits;
1459
+ input.value = national.slice(0, input.maxLength);
1460
+ }
1461
+ wrap.appendChild(select);
1462
+ wrap.appendChild(input);
1463
+ return { wrap, select, input };
1464
+ }
1465
+ function buildContactView(cfg) {
1466
+ const rc = cfg.requestContact;
1467
+ const view = document.createElement("div");
1468
+ view.className = "nexor-chat__contact-view";
1469
+ view.setAttribute("data-state", "form");
1470
+ const form = document.createElement("form");
1471
+ form.className = "nexor-chat__capture nexor-chat__contact-form";
1472
+ form.noValidate = true;
1473
+ const inputs = {};
1474
+ const ph = (f) => cfg.capture.placeholders[f] ?? DEFAULT_FIELD_PLACEHOLDERS[f];
1475
+ const row = document.createElement("div");
1476
+ row.className = "nexor-chat__capture-row";
1477
+ inputs.first_name = makeInput("first_name", ph("first_name"), "text", cfg.lead?.first_name);
1478
+ inputs.last_name = makeInput("last_name", ph("last_name"), "text", cfg.lead?.last_name);
1479
+ row.appendChild(inputs.first_name);
1480
+ row.appendChild(inputs.last_name);
1481
+ form.appendChild(row);
1482
+ inputs.email = makeInput("email", ph("email"), "email", cfg.lead?.email);
1483
+ form.appendChild(inputs.email);
1484
+ const phone = buildPhoneField(cfg, ph("phone"));
1485
+ inputs.phone = phone.input;
1486
+ const phoneCountry = phone.select;
1487
+ form.appendChild(phone.wrap);
1488
+ const error = document.createElement("p");
1489
+ error.className = "nexor-chat__capture-error";
1490
+ error.setAttribute("role", "alert");
1491
+ error.hidden = true;
1492
+ form.appendChild(error);
1493
+ const submitBtn = document.createElement("button");
1494
+ submitBtn.type = "submit";
1495
+ submitBtn.className = "nexor-chat__capture-submit";
1496
+ form.appendChild(submitBtn);
1497
+ view.appendChild(form);
1498
+ const success = document.createElement("div");
1499
+ success.className = "nexor-chat__contact-success";
1500
+ const successIcon = document.createElement("div");
1501
+ successIcon.className = "nexor-chat__contact-success-icon";
1502
+ successIcon.innerHTML = `
1503
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1504
+ <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
1505
+ </svg>`;
1506
+ const successText = document.createElement("p");
1507
+ successText.className = "nexor-chat__contact-success-text";
1508
+ const successBtn = document.createElement("button");
1509
+ successBtn.type = "button";
1510
+ successBtn.className = "nexor-chat__capture-submit";
1511
+ successBtn.textContent = rc?.doneLabel ?? "Done";
1512
+ success.appendChild(successIcon);
1513
+ success.appendChild(successText);
1514
+ success.appendChild(successBtn);
1515
+ view.appendChild(success);
1516
+ return {
1517
+ view,
1518
+ form,
1519
+ inputs,
1520
+ error,
1521
+ phoneCountry,
1522
+ submitBtn,
1523
+ success,
1524
+ successText,
1525
+ successBtn
1526
+ };
613
1527
  }
614
- function buildComposer() {
1528
+ function buildComposer(cfg) {
615
1529
  const form = document.createElement("form");
616
1530
  form.className = "nexor-chat__composer";
617
- const input = document.createElement("input");
618
- input.type = "text";
1531
+ const input = document.createElement("textarea");
619
1532
  input.className = "nexor-chat__input";
620
- input.placeholder = "Type a message\u2026";
621
- input.autocomplete = "off";
1533
+ input.placeholder = cfg.inputPlaceholder;
1534
+ input.rows = 1;
1535
+ input.setAttribute("autocomplete", "off");
622
1536
  const send = document.createElement("button");
623
1537
  send.type = "submit";
624
1538
  send.className = "nexor-chat__send";
@@ -633,10 +1547,23 @@ function buildComposer() {
633
1547
  form.appendChild(send);
634
1548
  return { form, input, send };
635
1549
  }
1550
+ var NEXOR_WORDMARK = `
1551
+ <svg viewBox="0 0 1722 505" fill="none" xmlns="http://www.w3.org/2000/svg" class="nexor-chat__brand-logo" role="img" aria-label="Nexor">
1552
+ <defs>
1553
+ <linearGradient id="nexorChatBrandGrad" x1="1195.44" y1="21.3423" x2="1195.44" y2="504.454" gradientUnits="userSpaceOnUse">
1554
+ <stop stop-color="#232522"/>
1555
+ <stop offset="1" stop-color="#A1A1A1"/>
1556
+ </linearGradient>
1557
+ </defs>
1558
+ <path d="M31.824 416.676V103.332H67.32L82.008 147.396C92.412 125.976 125.46 99.0477 196.452 99.0477C299.268 99.0477 319.464 159.024 319.464 247.764V416.676H254.592V242.256C254.592 186.564 244.8 153.516 178.092 153.516C111.384 153.516 97.308 185.952 97.308 242.256V416.676H31.824ZM506.373 420.96C380.301 420.96 349.089 343.236 349.089 259.392C349.089 175.548 380.913 99.0477 505.761 99.0477C621.429 99.0477 653.865 187.176 645.909 285.708H417.021C420.693 339.564 447.621 370.776 503.925 370.776C555.945 370.776 572.469 345.684 578.589 320.592H647.133C641.625 373.836 604.905 420.96 506.373 420.96ZM417.633 233.688H578.589V232.464C579.201 189.624 559.005 149.844 499.641 149.844C438.441 149.844 420.693 187.788 417.633 233.688ZM622.452 416.676L747.912 254.496L631.02 103.332H706.296L788.916 217.164L872.76 103.332H946.2L829.92 254.496L955.38 416.676H876.432L789.528 290.604L702.012 416.676H622.452Z" fill="#232522"/>
1559
+ <path d="M1195.22 21.3423C1199.01 21.3424 1202.55 23.2768 1205.81 26.6265C1209.06 29.9743 1212.14 34.8402 1215.03 40.9858C1220.81 53.2843 1225.98 70.9957 1230.31 92.7749C1236.49 123.842 1240.99 163.329 1243.06 207.497C1245.04 168.74 1249.06 134.088 1254.51 106.604C1258.48 86.5946 1263.23 70.315 1268.53 59.0063C1271.18 53.3557 1274 48.8741 1277 45.7866C1279.99 42.6974 1283.27 40.8979 1286.78 40.8979C1290.29 40.8981 1293.56 42.6975 1296.56 45.7866C1299.55 48.8741 1302.37 53.3556 1305.03 59.0063C1310.33 70.315 1315.07 86.5945 1319.04 106.604C1324.73 135.304 1328.87 171.821 1330.75 212.666C1332.39 186.451 1335.28 162.981 1339.08 144C1342.12 128.775 1345.76 116.368 1349.84 107.735C1351.88 103.422 1354.07 99.9788 1356.4 97.5962C1358.73 95.2117 1361.31 93.7866 1364.11 93.7866C1366.91 93.7866 1369.5 95.2116 1371.83 97.5962C1374.16 99.9788 1376.34 103.422 1378.38 107.735C1382.46 116.368 1386.1 128.775 1389.15 144C1393.72 166.881 1396.98 196.283 1398.32 229.154C1399.23 221.906 1400.42 215.317 1401.84 209.621C1403.54 202.788 1405.59 197.165 1407.92 193.217C1410.19 189.36 1412.99 186.675 1416.33 186.675C1419.68 186.676 1422.47 189.36 1424.75 193.217C1427.07 197.165 1429.12 202.788 1430.83 209.621C1434.24 223.306 1436.33 242.142 1436.33 262.898C1436.33 283.654 1434.24 302.489 1430.83 316.174C1429.12 323.007 1427.07 328.631 1424.75 332.579C1422.47 336.436 1419.68 339.119 1416.33 339.12C1412.99 339.12 1410.19 336.436 1407.92 332.579C1405.59 328.631 1403.54 323.007 1401.84 316.174C1400.42 310.478 1399.23 303.889 1398.32 296.641C1396.98 329.512 1393.72 358.915 1389.15 381.796C1386.1 397.021 1382.46 409.428 1378.38 418.061C1376.34 422.374 1374.16 425.816 1371.83 428.199C1369.5 430.583 1366.91 432.009 1364.11 432.009C1361.31 432.009 1358.73 430.584 1356.4 428.199C1354.07 425.816 1351.88 422.374 1349.84 418.061C1345.76 409.428 1342.12 397.021 1339.08 381.796C1335.28 362.814 1332.39 339.344 1330.75 313.129C1328.87 353.974 1324.73 390.492 1319.04 419.192C1315.07 439.201 1310.33 455.481 1305.03 466.79C1302.37 472.44 1299.55 476.922 1296.56 480.009C1293.56 483.098 1290.29 484.898 1286.78 484.898C1283.27 484.898 1279.99 483.098 1277 480.009C1274 476.922 1271.18 472.44 1268.53 466.79C1263.23 455.481 1258.48 439.201 1254.51 419.192C1249.06 391.708 1245.04 357.055 1243.06 318.298C1240.99 362.466 1236.49 401.953 1230.31 433.021C1225.98 454.8 1220.81 472.512 1215.03 484.81C1212.14 490.956 1209.06 495.822 1205.81 499.169C1202.55 502.519 1199.01 504.454 1195.22 504.454C1191.44 504.454 1187.89 502.519 1184.64 499.169C1181.38 495.822 1178.31 490.956 1175.42 484.81C1169.64 472.512 1164.46 454.8 1160.13 433.021C1153.95 401.956 1149.46 362.472 1147.39 318.308C1145.4 357.061 1141.38 391.71 1135.93 419.192C1131.96 439.202 1127.22 455.481 1121.91 466.79C1119.26 472.44 1116.44 476.922 1113.45 480.009C1110.45 483.098 1107.18 484.898 1103.67 484.898C1100.16 484.898 1096.88 483.098 1093.89 480.009C1090.89 476.922 1088.07 472.44 1085.42 466.79C1080.12 455.481 1075.37 439.202 1071.4 419.192C1065.71 390.491 1061.58 353.973 1059.69 313.128C1058.05 339.344 1055.16 362.814 1051.37 381.796C1048.32 397.021 1044.68 409.428 1040.6 418.061C1038.56 422.374 1036.38 425.816 1034.05 428.199C1031.72 430.583 1029.14 432.009 1026.33 432.009C1023.53 432.009 1020.95 430.584 1018.62 428.199C1016.29 425.816 1014.11 422.374 1012.07 418.061C1007.99 409.428 1004.34 397.021 1001.3 381.796C996.723 358.916 993.462 329.514 992.122 296.645C991.214 303.891 990.024 310.479 988.604 316.174C986.901 323.007 984.852 328.631 982.525 332.579C980.252 336.436 977.453 339.12 974.111 339.12C970.77 339.12 967.97 336.436 965.697 332.579C963.371 328.631 961.321 323.007 959.618 316.174C956.207 302.489 954.111 283.654 954.111 262.898C954.111 242.142 956.207 223.306 959.618 209.621C961.321 202.788 963.371 197.165 965.697 193.217C967.97 189.36 970.77 186.675 974.111 186.675C977.453 186.675 980.252 189.36 982.525 193.217C984.852 197.165 986.901 202.788 988.604 209.621C990.024 215.316 991.214 221.904 992.122 229.15C993.462 196.281 996.723 166.88 1001.3 144C1004.34 128.775 1007.99 116.368 1012.07 107.735C1014.11 103.422 1016.29 99.9787 1018.62 97.5962C1020.95 95.2115 1023.53 93.7866 1026.33 93.7866C1029.14 93.7868 1031.72 95.2116 1034.05 97.5962C1036.38 99.9787 1038.56 103.422 1040.6 107.735C1044.68 116.368 1048.32 128.775 1051.37 144C1055.16 162.981 1058.05 186.451 1059.69 212.666C1061.58 171.821 1065.71 135.304 1071.4 106.604C1075.37 86.5944 1080.12 70.315 1085.42 59.0063C1088.07 53.3558 1090.89 48.8741 1093.89 45.7866C1096.88 42.6974 1100.16 40.8979 1103.67 40.8979C1107.18 40.898 1110.45 42.6975 1113.45 45.7866C1116.44 48.8741 1119.26 53.3558 1121.91 59.0063C1127.22 70.315 1131.96 86.5945 1135.93 106.604C1141.38 134.086 1145.4 168.735 1147.39 207.487C1149.46 163.323 1153.95 123.84 1160.13 92.7749C1164.46 70.9957 1169.64 53.2843 1175.42 40.9858C1178.31 34.8403 1181.38 29.9743 1184.64 26.6265C1187.89 23.2769 1191.44 21.3423 1195.22 21.3423Z" fill="url(#nexorChatBrandGrad)"/>
1560
+ <path d="M1490.82 416.676V103.332H1525.71L1541.01 146.784C1559.98 106.392 1596.7 95.3758 1652.39 99.0477V155.964C1588.13 146.784 1556.31 177.996 1556.31 251.436V416.676H1490.82Z" fill="#232522"/>
1561
+ <path d="M1681.68 78.9118C1650.58 78.9118 1642.32 61.0558 1642.32 39.3598C1642.32 17.9518 1650.86 -0.000219345 1681.68 -0.000219345C1712.88 -0.000219345 1721.04 17.9518 1721.04 39.4558C1721.04 61.0558 1712.88 78.9118 1681.68 78.9118ZM1651.82 39.4558C1651.82 57.3118 1658.26 70.7518 1681.68 70.7518C1705.2 70.7518 1711.54 57.3118 1711.54 39.4558C1711.54 21.5998 1705.2 8.15978 1681.68 8.15978C1658.35 8.15978 1651.82 21.6958 1651.82 39.4558ZM1662.1 39.8398C1662.1 28.2238 1666.9 18.3358 1681.87 18.3358C1693.87 18.3358 1700.11 25.0558 1700.11 36.1918H1691.28C1690.7 29.2798 1687.82 26.3998 1681.78 26.3998C1674.1 26.3998 1671.02 31.2958 1671.02 39.5518C1671.02 47.9038 1674.1 52.8958 1681.78 52.8958C1687.82 52.8958 1690.8 50.0158 1691.28 43.3918H1700.11C1700.11 54.6238 1694.16 61.0558 1682.16 61.0558C1666.9 61.0558 1662.1 51.2638 1662.1 39.8398Z" fill="#232522"/>
1562
+ </svg>`;
636
1563
  function buildFooter() {
637
1564
  const el = document.createElement("div");
638
1565
  el.className = "nexor-chat__footer";
639
- el.innerHTML = `Powered by <a href="https://www.getnexor.ai" target="_blank" rel="noopener noreferrer">Nexor</a>`;
1566
+ el.innerHTML = `<span>Powered by</span><span class="nexor-chat__brand" aria-label="Nexor"><span class="nexor-chat__brand-tip" role="tooltip">getnexor.ai</span>${NEXOR_WORDMARK}</span>`;
640
1567
  return el;
641
1568
  }
642
1569
  function buildMessage(role, text, ts, locale) {
@@ -681,6 +1608,20 @@ function formatRelativeTime(ts, locale, now = Date.now()) {
681
1608
  }
682
1609
  }
683
1610
  }
1611
+ function buildResumeBanner(text, resetLabel) {
1612
+ const el = document.createElement("div");
1613
+ el.className = "nexor-chat__resume";
1614
+ const label = document.createElement("span");
1615
+ label.className = "nexor-chat__resume-text";
1616
+ label.textContent = text;
1617
+ const resetBtn = document.createElement("button");
1618
+ resetBtn.type = "button";
1619
+ resetBtn.className = "nexor-chat__resume-reset";
1620
+ resetBtn.textContent = resetLabel;
1621
+ el.appendChild(label);
1622
+ el.appendChild(resetBtn);
1623
+ return { el, resetBtn };
1624
+ }
684
1625
  function buildTypingIndicator() {
685
1626
  const el = document.createElement("div");
686
1627
  el.className = "nexor-chat__typing";
@@ -695,12 +1636,8 @@ function makeInput(name, placeholder, type, value) {
695
1636
  if (value) i.value = value;
696
1637
  return i;
697
1638
  }
698
- function escapeHtml(s) {
699
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
700
- }
701
1639
 
702
1640
  // src/chat/index.ts
703
- var TYPING_DELAY_MS = 220;
704
1641
  function initChat(client, options) {
705
1642
  ensureBrowser();
706
1643
  if (!options || !options.workflowId) {
@@ -708,95 +1645,603 @@ function initChat(client, options) {
708
1645
  }
709
1646
  const cfg = applyDefaults(options);
710
1647
  injectStyles(cfg.accentColor, cfg.accentTextColor);
711
- const sessionId = ensureSessionId();
712
- const historyKey = `nexor.chat.history.${sessionId}`;
1648
+ let sessionId = ensureSessionId();
1649
+ let historyKey = `nexor.chat.history.${sessionId}`;
713
1650
  const root = buildRoot(cfg);
714
- const launcher = buildLauncher();
1651
+ const launcher = buildLauncher(cfg);
715
1652
  const panel = buildPanel(cfg);
716
- root.appendChild(launcher.el);
1653
+ root.appendChild(launcher.wrap);
717
1654
  root.appendChild(panel.el);
718
1655
  (cfg.container ?? document.body).appendChild(root);
1656
+ const hasFan = launcher.channelButtons.length > 0;
719
1657
  const state = {
720
1658
  isOpen: false,
1659
+ expanded: false,
1660
+ // speed-dial fan visible (touch/click-pinned)
721
1661
  unread: 0,
722
1662
  leadId: void 0,
723
1663
  captured: !needsCapture(cfg),
724
1664
  pendingLead: { ...cfg.lead ?? {} },
725
- history: []
1665
+ history: [],
1666
+ // Dashboard-authored web-chat config (fetched once on init). Until it
1667
+ // resolves, assume enabled and fall back to the host-provided `greeting`.
1668
+ webchatEnabled: true,
1669
+ serverInitialMessage: null,
1670
+ configResolved: false,
1671
+ disabledShown: false,
1672
+ // The opening message actually displayed to the visitor — sent to the API
1673
+ // so the agent's <webchat_opening> matches what was shown (esp. for a
1674
+ // per-page openingMessage override).
1675
+ shownOpening: null,
1676
+ // Set by "start over" → the next turn tells the API to reset the conversation
1677
+ // episode (keep the lead, clear its prior run/thread/variables). Cleared once
1678
+ // that turn lands.
1679
+ resetEpisode: false,
1680
+ // True when this page load started with NO restored transcript. Used to
1681
+ // decide whether to offer the "continue / start fresh" choice when the API
1682
+ // reports it recognised a returning lead (matched_existing).
1683
+ freshLocalSession: false,
1684
+ // Ensures the returning-lead choice banner is offered at most once.
1685
+ episodeChoiceOffered: false,
1686
+ // The channel a visitor picked in the contact-request form (call/email/...).
1687
+ contactChannel: null,
1688
+ // In-flight "create lead on form submit" request — the first chat turn waits
1689
+ // on it so the backend matches that lead (no duplicate) rather than racing it.
1690
+ leadCreating: null
726
1691
  };
727
- launcher.el.addEventListener("click", toggle);
1692
+ let configReady;
1693
+ const send = {
1694
+ pending: [],
1695
+ timer: 0,
1696
+ inFlight: false,
1697
+ typing: null,
1698
+ splitTimer: 0
1699
+ // staggers multi-bubble bot replies
1700
+ };
1701
+ const greeting = { pending: false, timer: 0 };
1702
+ const GREETING_DELAY_MS = 2e3;
1703
+ launcher.el.addEventListener("click", hasFan ? toggleFan : toggle);
728
1704
  panel.closeBtn.addEventListener("click", close);
1705
+ panel.restartBtn?.addEventListener("click", startOver);
729
1706
  panel.form.addEventListener("submit", onComposerSubmit);
1707
+ panel.input.addEventListener("input", autosizeComposer);
1708
+ panel.input.addEventListener("keydown", (e) => {
1709
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
1710
+ e.preventDefault();
1711
+ onComposerSubmit(e);
1712
+ }
1713
+ });
730
1714
  panel.captureForm?.addEventListener("submit", onCaptureSubmit);
731
- syncComposerLock();
732
- const restored = loadHistory();
733
- if (restored.length) {
734
- state.history = restored;
735
- for (const m of restored) appendBubble(m.role === "user" ? "user" : "bot", m.text, m.ts);
1715
+ for (const input of Object.values(panel.captureInputs)) {
1716
+ input?.addEventListener("input", clearCaptureError);
1717
+ }
1718
+ if (panel.phoneCountry && panel.captureInputs.phone) {
1719
+ wirePhoneCap(panel.phoneCountry, panel.captureInputs.phone, clearCaptureError);
1720
+ }
1721
+ if (panel.contact) {
1722
+ const c = panel.contact;
1723
+ c.form.addEventListener("submit", onContactSubmit);
1724
+ for (const input of Object.values(c.inputs)) {
1725
+ input?.addEventListener("input", clearContactError);
1726
+ }
1727
+ if (c.phoneCountry && c.inputs.phone) {
1728
+ wirePhoneCap(c.phoneCountry, c.inputs.phone, clearContactError);
1729
+ }
1730
+ c.successBtn.addEventListener("click", close);
1731
+ }
1732
+ let onDocPointerDown = null;
1733
+ if (hasFan) {
1734
+ for (const btn of launcher.channelButtons) {
1735
+ btn.el.addEventListener("click", () => {
1736
+ if (btn.key === "chat") {
1737
+ collapseFan();
1738
+ open();
1739
+ } else if (btn.action === "contact") {
1740
+ collapseFan();
1741
+ openContact(btn.key);
1742
+ } else {
1743
+ collapseFan();
1744
+ }
1745
+ });
1746
+ }
1747
+ onDocPointerDown = (e) => {
1748
+ if (state.expanded && !launcher.wrap.contains(e.target)) collapseFan();
1749
+ };
1750
+ document.addEventListener("pointerdown", onDocPointerDown);
1751
+ }
1752
+ let resumeBanner = null;
1753
+ const persisted = loadPersisted();
1754
+ state.freshLocalSession = persisted.history.length === 0;
1755
+ if (persisted.history.length) {
1756
+ state.history = persisted.history;
1757
+ if (persisted.lead) state.pendingLead = { ...state.pendingLead, ...persisted.lead };
1758
+ state.captured = true;
1759
+ setView("chat");
1760
+ renderResumeBanner();
1761
+ for (const m of persisted.history) {
1762
+ appendBubble(m.role === "user" ? "user" : "bot", m.text, m.ts);
1763
+ }
736
1764
  scrollToBottom();
737
1765
  }
1766
+ syncComposerLock();
1767
+ configReady = loadServerConfig();
738
1768
  if (cfg.openOnLoad) open();
1769
+ function toggleFan() {
1770
+ state.expanded ? collapseFan() : expandFan();
1771
+ }
1772
+ function expandFan() {
1773
+ state.expanded = true;
1774
+ launcher.wrap.setAttribute("data-expanded", "true");
1775
+ launcher.el.setAttribute("aria-expanded", "true");
1776
+ }
1777
+ function collapseFan() {
1778
+ if (!state.expanded) return;
1779
+ state.expanded = false;
1780
+ launcher.wrap.setAttribute("data-expanded", "false");
1781
+ launcher.el.setAttribute("aria-expanded", "false");
1782
+ }
739
1783
  function open() {
1784
+ collapseFan();
740
1785
  if (state.isOpen) return;
741
1786
  state.isOpen = true;
742
1787
  root.setAttribute("data-open", "true");
743
1788
  clearUnread();
744
- if (state.history.length === 0 && cfg.greeting && state.captured) {
745
- appendBot(cfg.greeting);
1789
+ if (!state.webchatEnabled) {
1790
+ showDisabled();
1791
+ cfg.onOpen?.();
1792
+ return;
1793
+ }
1794
+ if (state.history.length === 0 && state.captured && canGreet()) {
1795
+ scheduleGreeting();
1796
+ }
1797
+ if (!state.captured) {
1798
+ const first = cfg.capture.fields[0];
1799
+ (first && panel.captureInputs[first] || panel.input).focus();
1800
+ } else {
1801
+ panel.input.focus();
746
1802
  }
747
- panel.input.focus();
748
1803
  cfg.onOpen?.();
749
1804
  }
750
1805
  function close() {
751
1806
  if (!state.isOpen) return;
752
1807
  state.isOpen = false;
753
1808
  root.setAttribute("data-open", "false");
1809
+ if (panel.el.getAttribute("data-view") === "contact") {
1810
+ state.contactChannel = null;
1811
+ setView(baseView());
1812
+ restoreHeader();
1813
+ }
754
1814
  cfg.onClose?.();
755
1815
  }
1816
+ function restoreHeader() {
1817
+ panel.headerTitle.textContent = cfg.title;
1818
+ panel.headerSubtitleText.textContent = cfg.subtitle ?? "";
1819
+ panel.headerSubtitle.hidden = !cfg.subtitle;
1820
+ }
756
1821
  function toggle() {
757
1822
  state.isOpen ? close() : open();
758
1823
  }
759
1824
  function onCaptureSubmit(e) {
760
1825
  e.preventDefault();
1826
+ clearCaptureError();
761
1827
  const collected = readCaptureFields(panel);
762
1828
  const required = cfg.capture.fields;
1829
+ const msgs = cfg.capture.errorMessages;
763
1830
  for (const f of required) {
764
- if (!collected[f]) {
765
- panel.captureInputs[f]?.focus();
766
- return;
1831
+ if (!collected[f]) return failCapture(f, msgs.required);
1832
+ }
1833
+ if (required.includes("email") && !isValidEmail(collected.email ?? "")) {
1834
+ return failCapture("email", msgs.email);
1835
+ }
1836
+ if (required.includes("phone")) {
1837
+ const country = countryByIso(panel.phoneCountry?.value);
1838
+ const digits = (collected.phone ?? "").replace(/\D/g, "");
1839
+ if (!country || digits.length < country.nsn[0] || digits.length > country.nsn[1]) {
1840
+ return failCapture("phone", msgs.phone);
767
1841
  }
1842
+ collected.phone = `${country.dial}${digits}`;
768
1843
  }
769
1844
  state.pendingLead = { ...state.pendingLead, ...collected };
770
1845
  state.captured = true;
771
- panel.captureForm?.remove();
1846
+ persistCapturedLead();
1847
+ setView("chat");
772
1848
  syncComposerLock();
773
1849
  panel.input.focus();
774
- if (cfg.greeting && state.history.length === 0) appendBot(cfg.greeting);
1850
+ if (state.history.length === 0 && canGreet()) scheduleGreeting();
1851
+ }
1852
+ function persistCapturedLead() {
1853
+ if (!cfg.capture.createLeadOnSubmit || state.leadId) return;
1854
+ const lead = state.pendingLead;
1855
+ if (!lead.first_name && !lead.email && !lead.phone) return;
1856
+ const resolvedMeta = typeof cfg.metadata === "function" ? cfg.metadata() : cfg.metadata;
1857
+ state.leadCreating = client.createLead({
1858
+ first_name: lead.first_name || "Web visitor",
1859
+ last_name: lead.last_name,
1860
+ email: lead.email,
1861
+ phone: lead.phone,
1862
+ source: "web-chat",
1863
+ metadata: {
1864
+ ...resolvedMeta,
1865
+ web_chat_session_id: sessionId
1866
+ }
1867
+ }).then((res) => {
1868
+ if (res?.lead?.id && !state.leadId) {
1869
+ state.leadId = res.lead.id;
1870
+ cfg.onLeadCaptured?.(res.lead.id);
1871
+ }
1872
+ if (res && !res.existed) state.episodeChoiceOffered = true;
1873
+ }).catch((err) => {
1874
+ cfg.onError?.(err);
1875
+ }).finally(() => {
1876
+ state.leadCreating = null;
1877
+ });
1878
+ }
1879
+ function failCapture(field, message) {
1880
+ showCaptureError(message);
1881
+ const input = panel.captureInputs[field];
1882
+ const target = input?.closest(".nexor-chat__phone") || input;
1883
+ target?.classList.add("nexor-chat__input--error");
1884
+ input?.focus();
1885
+ }
1886
+ function showCaptureError(message) {
1887
+ if (!panel.captureError) return;
1888
+ panel.captureError.textContent = message;
1889
+ panel.captureError.hidden = false;
1890
+ }
1891
+ function clearCaptureError() {
1892
+ if (panel.captureError) {
1893
+ panel.captureError.textContent = "";
1894
+ panel.captureError.hidden = true;
1895
+ }
1896
+ panel.captureForm?.querySelectorAll(".nexor-chat__input--error").forEach((el) => el.classList.remove("nexor-chat__input--error"));
1897
+ }
1898
+ function wirePhoneCap(select, input, onChange) {
1899
+ const cap = () => {
1900
+ const c = countryByIso(select.value);
1901
+ const max = c ? maxNsn(c) : 15;
1902
+ input.maxLength = max;
1903
+ const digits = input.value.replace(/\D/g, "").slice(0, max);
1904
+ if (input.value !== digits) input.value = digits;
1905
+ };
1906
+ input.addEventListener("input", cap);
1907
+ select.addEventListener("change", () => {
1908
+ cap();
1909
+ onChange?.();
1910
+ });
1911
+ }
1912
+ function openContact(channel) {
1913
+ const c = panel.contact;
1914
+ const rc = cfg.requestContact;
1915
+ if (!c || !rc) return;
1916
+ state.contactChannel = channel;
1917
+ panel.headerTitle.textContent = rc.title[channel] ?? cfg.title;
1918
+ panel.headerSubtitleText.textContent = rc.subtitle[channel] ?? "";
1919
+ panel.headerSubtitle.hidden = !rc.subtitle[channel];
1920
+ c.submitBtn.textContent = rc.submitLabel[channel] ?? "";
1921
+ c.successText.textContent = rc.successText[channel] ?? "";
1922
+ c.view.setAttribute("data-state", "form");
1923
+ c.submitBtn.disabled = false;
1924
+ clearContactError();
1925
+ resetContactForm();
1926
+ cancelGreeting();
1927
+ state.isOpen = true;
1928
+ root.setAttribute("data-open", "true");
1929
+ clearUnread();
1930
+ setView("contact");
1931
+ const firstEmpty = [c.inputs.first_name, c.inputs.email, c.inputs.phone].find(
1932
+ (i) => i && !i.value
1933
+ );
1934
+ (firstEmpty ?? c.inputs.first_name ?? null)?.focus();
1935
+ cfg.onOpen?.();
1936
+ }
1937
+ function resetContactForm() {
1938
+ const c = panel.contact;
1939
+ if (!c) return;
1940
+ const lead = state.pendingLead;
1941
+ if (c.inputs.first_name) c.inputs.first_name.value = lead.first_name ?? "";
1942
+ if (c.inputs.last_name) c.inputs.last_name.value = lead.last_name ?? "";
1943
+ if (c.inputs.email) c.inputs.email.value = lead.email ?? "";
1944
+ const def = resolveDefaultCountry(cfg.capture.phoneCountryCode);
1945
+ if (c.phoneCountry) c.phoneCountry.value = def.iso;
1946
+ if (c.inputs.phone) {
1947
+ let national = "";
1948
+ if (lead.phone) {
1949
+ const digits = String(lead.phone).replace(/\D/g, "");
1950
+ const dialDigits = def.dial.replace(/\D/g, "");
1951
+ national = (digits.startsWith(dialDigits) ? digits.slice(dialDigits.length) : digits).slice(
1952
+ 0,
1953
+ maxNsn(def)
1954
+ );
1955
+ }
1956
+ c.inputs.phone.maxLength = maxNsn(def);
1957
+ c.inputs.phone.value = national;
1958
+ }
1959
+ }
1960
+ function onContactSubmit(e) {
1961
+ e.preventDefault();
1962
+ const c = panel.contact;
1963
+ const channel = state.contactChannel;
1964
+ if (!c || !channel) return;
1965
+ clearContactError();
1966
+ const msgs = cfg.capture.errorMessages;
1967
+ const first = c.inputs.first_name?.value.trim() ?? "";
1968
+ const last = c.inputs.last_name?.value.trim() ?? "";
1969
+ const email = c.inputs.email?.value.trim() ?? "";
1970
+ const phoneDigits = (c.inputs.phone?.value ?? "").replace(/\D/g, "");
1971
+ if (!first) return failContact("first_name", msgs.required);
1972
+ const needEmail = channel === "email";
1973
+ if (needEmail && !email) return failContact("email", msgs.required);
1974
+ if (email && !isValidEmail(email)) return failContact("email", msgs.email);
1975
+ const needPhone = channel === "call" || channel === "whatsapp";
1976
+ let e164;
1977
+ if (needPhone || phoneDigits) {
1978
+ const country = countryByIso(c.phoneCountry?.value);
1979
+ if (!country || phoneDigits.length < country.nsn[0] || phoneDigits.length > country.nsn[1]) {
1980
+ return failContact("phone", msgs.phone);
1981
+ }
1982
+ e164 = `${country.dial}${phoneDigits}`;
1983
+ }
1984
+ void submitContact(channel, { first, last, email, phone: e164 });
1985
+ }
1986
+ async function submitContact(channel, data) {
1987
+ const c = panel.contact;
1988
+ const rc = cfg.requestContact;
1989
+ if (!c || !rc) return;
1990
+ c.submitBtn.disabled = true;
1991
+ const resolvedMeta = typeof cfg.metadata === "function" ? cfg.metadata() : cfg.metadata;
1992
+ const meta = resolvedMeta ?? void 0;
1993
+ const pageUrl = meta && meta.page_url || (typeof window !== "undefined" ? window.location.href : void 0);
1994
+ try {
1995
+ const res = await client.requestContact(
1996
+ {
1997
+ workflow_id: cfg.workflowId,
1998
+ first_name: data.first,
1999
+ last_name: data.last || void 0,
2000
+ email: data.email || void 0,
2001
+ phone: data.phone,
2002
+ force_first_channel: channel,
2003
+ source: "web-chat",
2004
+ // Context for the agent's first outreach: they were on the site and
2005
+ // explicitly asked to be reached on this channel.
2006
+ metadata: {
2007
+ ...meta ?? {},
2008
+ contact_request: true,
2009
+ requested_channel: channel,
2010
+ requested_at: (/* @__PURE__ */ new Date()).toISOString(),
2011
+ context_note: `Website visitor asked to be contacted via ${channel}${pageUrl ? ` from ${pageUrl}` : ""}.`
2012
+ }
2013
+ },
2014
+ { timeoutMs: cfg.requestTimeoutMs, maxRetries: 0 }
2015
+ );
2016
+ if (res?.lead?.id && !state.leadId) {
2017
+ state.leadId = res.lead.id;
2018
+ cfg.onLeadCaptured?.(res.lead.id);
2019
+ }
2020
+ c.successText.textContent = rc.successText[channel] ?? "";
2021
+ panel.headerSubtitle.hidden = true;
2022
+ c.view.setAttribute("data-state", "sent");
2023
+ } catch (err) {
2024
+ c.submitBtn.disabled = false;
2025
+ showContactError(rc.errorText);
2026
+ cfg.onError?.(err);
2027
+ }
2028
+ }
2029
+ function failContact(field, message) {
2030
+ const c = panel.contact;
2031
+ if (!c) return;
2032
+ showContactError(message);
2033
+ const input = c.inputs[field];
2034
+ const target = input?.closest(".nexor-chat__phone") || input;
2035
+ target?.classList.add("nexor-chat__input--error");
2036
+ input?.focus();
2037
+ }
2038
+ function showContactError(message) {
2039
+ const c = panel.contact;
2040
+ if (!c) return;
2041
+ c.error.textContent = message;
2042
+ c.error.hidden = false;
2043
+ }
2044
+ function clearContactError() {
2045
+ const c = panel.contact;
2046
+ if (!c) return;
2047
+ c.error.textContent = "";
2048
+ c.error.hidden = true;
2049
+ c.form.querySelectorAll(".nexor-chat__input--error").forEach((el) => el.classList.remove("nexor-chat__input--error"));
2050
+ }
2051
+ function setView(view) {
2052
+ panel.el.setAttribute("data-view", view);
2053
+ }
2054
+ function baseView() {
2055
+ return state.captured ? "chat" : "capture";
2056
+ }
2057
+ function identityText() {
2058
+ const l = state.pendingLead;
2059
+ const who = (l.first_name || l.email || l.phone || "").trim();
2060
+ return who ? cfg.resumeWithNameText.replace("{name}", who) : cfg.resumeText;
2061
+ }
2062
+ function renderResumeBanner(prepend = false) {
2063
+ if (resumeBanner) return;
2064
+ const banner = buildResumeBanner(identityText(), cfg.startOverText);
2065
+ banner.resetBtn.addEventListener("click", startOver);
2066
+ if (prepend && panel.body.firstChild) {
2067
+ panel.body.insertBefore(banner.el, panel.body.firstChild);
2068
+ } else {
2069
+ panel.body.appendChild(banner.el);
2070
+ }
2071
+ resumeBanner = banner.el;
2072
+ }
2073
+ function startOver() {
2074
+ cancelGreeting();
2075
+ if (send.timer) {
2076
+ window.clearTimeout(send.timer);
2077
+ send.timer = 0;
2078
+ }
2079
+ if (send.splitTimer) {
2080
+ window.clearTimeout(send.splitTimer);
2081
+ send.splitTimer = 0;
2082
+ }
2083
+ send.pending = [];
2084
+ hideTyping();
2085
+ try {
2086
+ window.localStorage.removeItem(historyKey);
2087
+ } catch {
2088
+ }
2089
+ state.history = [];
2090
+ state.shownOpening = null;
2091
+ resumeBanner = null;
2092
+ panel.body.replaceChildren();
2093
+ if (panel.captureView) {
2094
+ state.pendingLead = { ...cfg.lead ?? {} };
2095
+ state.captured = false;
2096
+ state.leadId = void 0;
2097
+ state.episodeChoiceOffered = false;
2098
+ state.resetEpisode = true;
2099
+ resetCaptureForm();
2100
+ setView("capture");
2101
+ syncComposerLock();
2102
+ const first = cfg.capture.fields[0];
2103
+ (first && panel.captureInputs[first] || panel.input).focus();
2104
+ return;
2105
+ }
2106
+ state.resetEpisode = true;
2107
+ setView("chat");
2108
+ syncComposerLock();
2109
+ if (state.captured && canGreet()) scheduleGreeting();
2110
+ panel.input.focus();
2111
+ }
2112
+ function resetCaptureForm() {
2113
+ const lead = state.pendingLead;
2114
+ if (panel.captureInputs.first_name) panel.captureInputs.first_name.value = lead.first_name ?? "";
2115
+ if (panel.captureInputs.last_name) panel.captureInputs.last_name.value = lead.last_name ?? "";
2116
+ if (panel.captureInputs.email) panel.captureInputs.email.value = lead.email ?? "";
2117
+ const def = resolveDefaultCountry(cfg.capture.phoneCountryCode);
2118
+ if (panel.phoneCountry) panel.phoneCountry.value = def.iso;
2119
+ if (panel.captureInputs.phone) {
2120
+ let national = "";
2121
+ if (lead.phone) {
2122
+ const digits = String(lead.phone).replace(/\D/g, "");
2123
+ const dialDigits = def.dial.replace(/\D/g, "");
2124
+ national = (digits.startsWith(dialDigits) ? digits.slice(dialDigits.length) : digits).slice(
2125
+ 0,
2126
+ maxNsn(def)
2127
+ );
2128
+ }
2129
+ panel.captureInputs.phone.maxLength = maxNsn(def);
2130
+ panel.captureInputs.phone.value = national;
2131
+ }
2132
+ clearCaptureError();
775
2133
  }
776
- const send = {
777
- pending: [],
778
- timer: 0,
779
- typingTimer: 0,
780
- inFlight: false,
781
- typing: null
782
- };
783
2134
  function onComposerSubmit(e) {
784
2135
  e.preventDefault();
785
2136
  const text = panel.input.value.trim();
786
2137
  if (!text) return;
787
2138
  panel.input.value = "";
2139
+ autosizeComposer();
788
2140
  queueMessage(text);
789
2141
  }
2142
+ function autosizeComposer() {
2143
+ const el = panel.input;
2144
+ el.style.height = "auto";
2145
+ el.style.height = `${el.scrollHeight + 2}px`;
2146
+ }
790
2147
  function queueMessage(text) {
791
2148
  if (!state.captured) {
792
- panel.captureInputs.first_name?.focus();
2149
+ const first = cfg.capture.fields[0];
2150
+ (first && panel.captureInputs[first] || panel.input).focus();
793
2151
  return;
794
2152
  }
2153
+ cancelGreeting();
795
2154
  appendUser(text);
796
2155
  send.pending.push(text);
797
2156
  scheduleFlush();
798
2157
  }
2158
+ function greetingText() {
2159
+ const raw = cfg.openingMessage || state.serverInitialMessage;
2160
+ if (raw) return renderLeadTemplate(raw, state.pendingLead);
2161
+ return cfg.greeting ?? "";
2162
+ }
2163
+ function canGreet() {
2164
+ if (!state.webchatEnabled) return false;
2165
+ if (cfg.openingMessage) return true;
2166
+ if (!state.configResolved) return true;
2167
+ return !!state.serverInitialMessage || !!cfg.greeting;
2168
+ }
2169
+ function scheduleGreeting() {
2170
+ if (greeting.pending) return;
2171
+ greeting.pending = true;
2172
+ showTyping();
2173
+ greeting.timer = window.setTimeout(async () => {
2174
+ if (!cfg.openingMessage) await configReady;
2175
+ if (!greeting.pending) return;
2176
+ greeting.pending = false;
2177
+ greeting.timer = 0;
2178
+ hideTyping();
2179
+ const text = greetingText();
2180
+ if (text) {
2181
+ state.shownOpening = text;
2182
+ appendBot(text);
2183
+ }
2184
+ }, GREETING_DELAY_MS);
2185
+ }
2186
+ function cancelGreeting() {
2187
+ if (!greeting.pending) return;
2188
+ if (greeting.timer) window.clearTimeout(greeting.timer);
2189
+ greeting.timer = 0;
2190
+ greeting.pending = false;
2191
+ hideTyping();
2192
+ }
2193
+ async function loadServerConfig() {
2194
+ try {
2195
+ const res = await client.getChatConfig(cfg.workflowId, {
2196
+ timeoutMs: 1e4,
2197
+ maxRetries: 1
2198
+ });
2199
+ applyServerConfig(res.enabled !== false, res.initial_message ?? null);
2200
+ } catch {
2201
+ applyServerConfig(true, null);
2202
+ }
2203
+ }
2204
+ function applyServerConfig(enabled, initialMessage) {
2205
+ state.configResolved = true;
2206
+ state.serverInitialMessage = initialMessage;
2207
+ state.webchatEnabled = enabled;
2208
+ if (!enabled) {
2209
+ hideChatChannelButton();
2210
+ cancelGreeting();
2211
+ if (state.isOpen) showDisabled();
2212
+ }
2213
+ }
2214
+ function showDisabled() {
2215
+ setView("chat");
2216
+ if (!state.disabledShown) {
2217
+ appendSystem(cfg.disabledText);
2218
+ state.disabledShown = true;
2219
+ }
2220
+ panel.input.disabled = true;
2221
+ panel.send.disabled = true;
2222
+ panel.input.placeholder = cfg.disabledText;
2223
+ }
2224
+ function hideChatChannelButton() {
2225
+ for (const btn of launcher.channelButtons) {
2226
+ if (btn.key !== "chat") continue;
2227
+ const item = btn.el.closest(".nexor-chat__channel-item");
2228
+ if (item instanceof HTMLElement) item.style.display = "none";
2229
+ }
2230
+ }
2231
+ function showTyping() {
2232
+ if (send.typing) {
2233
+ panel.body.appendChild(send.typing);
2234
+ scrollToBottom();
2235
+ return;
2236
+ }
2237
+ send.typing = appendNode(buildTypingIndicator());
2238
+ }
2239
+ function hideTyping() {
2240
+ send.typing?.remove();
2241
+ send.typing = null;
2242
+ }
799
2243
  function scheduleFlush() {
2244
+ showTyping();
800
2245
  if (send.timer) window.clearTimeout(send.timer);
801
2246
  send.timer = window.setTimeout(() => void flush(), cfg.debounceMs);
802
2247
  }
@@ -805,37 +2250,65 @@ function initChat(client, options) {
805
2250
  const batch = send.pending.splice(0, send.pending.length);
806
2251
  const message = batch.join("\n");
807
2252
  send.inFlight = true;
808
- send.typingTimer = window.setTimeout(() => {
809
- send.typing = appendNode(buildTypingIndicator());
810
- }, TYPING_DELAY_MS);
2253
+ if (state.leadCreating) {
2254
+ try {
2255
+ await state.leadCreating;
2256
+ } catch {
2257
+ }
2258
+ }
2259
+ const turnResetsEpisode = state.resetEpisode;
811
2260
  try {
812
- const res = await client.chat({
813
- workflow_id: cfg.workflowId,
814
- session_id: sessionId,
815
- message,
816
- system_prompt: cfg.systemPrompt,
817
- client_prompt: cfg.clientPrompt,
818
- lead: Object.keys(state.pendingLead).length ? state.pendingLead : void 0,
819
- metadata: cfg.metadata
820
- });
2261
+ const res = await client.chat(
2262
+ {
2263
+ workflow_id: cfg.workflowId,
2264
+ session_id: sessionId,
2265
+ message,
2266
+ // Additive page/instance prompt injection (API appends to the
2267
+ // workflow persona). Per route, the host passes different values.
2268
+ system_prompt: cfg.systemPrompt,
2269
+ client_prompt: cfg.clientPrompt,
2270
+ // The opening the visitor actually saw, so the agent doesn't repeat
2271
+ // or contradict a per-page opening override.
2272
+ opening_shown: state.shownOpening ?? void 0,
2273
+ // First turn after "start over" → reset the conversation episode on
2274
+ // the same lead (clear prior run/thread/variables server-side).
2275
+ reset_episode: turnResetsEpisode || void 0,
2276
+ lead: Object.keys(state.pendingLead).length ? state.pendingLead : void 0,
2277
+ // Resolve metadata per-turn so a function form picks up live SPA state
2278
+ // (e.g. the current route) without re-mounting the widget.
2279
+ metadata: typeof cfg.metadata === "function" ? cfg.metadata() : cfg.metadata
2280
+ },
2281
+ // A chat turn is NOT idempotent (re-sending re-runs the LLM and
2282
+ // re-inserts the message). Never retry; just give it a generous timeout.
2283
+ { timeoutMs: cfg.requestTimeoutMs, maxRetries: 0 }
2284
+ );
2285
+ if (turnResetsEpisode) state.resetEpisode = false;
821
2286
  if (res.lead_id && !state.leadId) {
822
2287
  state.leadId = res.lead_id;
823
2288
  cfg.onLeadCaptured?.(state.leadId);
824
2289
  }
2290
+ if (res.matched_existing && state.freshLocalSession && !state.episodeChoiceOffered && !turnResetsEpisode) {
2291
+ state.episodeChoiceOffered = true;
2292
+ renderResumeBanner(true);
2293
+ }
825
2294
  const reply = (res.reply ?? "").trim();
2295
+ hideTyping();
826
2296
  if (reply) {
827
- appendBot(reply);
2297
+ appendBotReply(reply);
828
2298
  } else {
829
2299
  appendSystem(cfg.errorText);
830
2300
  cfg.onError?.(new Error("Nexor SDK: empty reply from server"));
831
2301
  }
832
2302
  } catch (err) {
833
- appendSystem(cfg.errorText);
2303
+ hideTyping();
2304
+ if (/web chat is not enabled/i.test(err?.message || "")) {
2305
+ applyServerConfig(false, state.serverInitialMessage);
2306
+ } else {
2307
+ appendSystem(cfg.errorText);
2308
+ }
834
2309
  cfg.onError?.(err);
835
2310
  } finally {
836
- window.clearTimeout(send.typingTimer);
837
- send.typing?.remove();
838
- send.typing = null;
2311
+ if (!send.splitTimer) hideTyping();
839
2312
  send.inFlight = false;
840
2313
  panel.input.focus();
841
2314
  if (send.pending.length) scheduleFlush();
@@ -848,37 +2321,84 @@ function initChat(client, options) {
848
2321
  saveHistory();
849
2322
  cfg.onMessage?.({ role: "user", text });
850
2323
  }
851
- function appendBot(text) {
852
- const ts = Date.now();
2324
+ function appendBot(text, showTime = true) {
2325
+ const ts = showTime ? Date.now() : void 0;
853
2326
  appendBubble("bot", text, ts);
854
2327
  state.history.push({ role: "bot", text, ts });
855
2328
  saveHistory();
856
2329
  if (!state.isOpen) bumpUnread();
857
2330
  cfg.onMessage?.({ role: "bot", text });
858
2331
  }
2332
+ function appendBotReply(text) {
2333
+ const parts = cfg.splitReplies ? splitReply(text) : [text];
2334
+ if (parts.length <= 1) {
2335
+ appendBot(text);
2336
+ return;
2337
+ }
2338
+ const [first, ...rest] = parts;
2339
+ appendBot(first ?? text, false);
2340
+ let i = 0;
2341
+ const sendNext = () => {
2342
+ const part = rest[i];
2343
+ if (part === void 0) return;
2344
+ const isLast = i === rest.length - 1;
2345
+ i++;
2346
+ showTyping();
2347
+ const delay = cfg.splitDelayMs > 0 ? cfg.splitDelayMs : typingDelayFor(part);
2348
+ send.splitTimer = window.setTimeout(() => {
2349
+ send.splitTimer = 0;
2350
+ hideTyping();
2351
+ appendBot(part, isLast);
2352
+ sendNext();
2353
+ }, delay);
2354
+ };
2355
+ sendNext();
2356
+ }
2357
+ function splitReply(text) {
2358
+ const parts = text.split(/\n[ \t]*\n+/).map((s) => s.trim()).filter(Boolean);
2359
+ return parts.length ? parts : [text];
2360
+ }
2361
+ function typingDelayFor(text) {
2362
+ const MIN = 3e3;
2363
+ const MAX = 5e3;
2364
+ const FULL_AT = 180;
2365
+ const scaled = MIN + Math.min(text.length, FULL_AT) * ((MAX - MIN) / FULL_AT);
2366
+ const jitter = Math.random() * 300 - 150;
2367
+ return Math.round(Math.max(MIN, Math.min(MAX, scaled + jitter)));
2368
+ }
859
2369
  function saveHistory() {
860
2370
  try {
861
2371
  window.localStorage.setItem(
862
2372
  historyKey,
863
- JSON.stringify({ ts: Date.now(), history: state.history })
2373
+ // Persist the known lead alongside the transcript so a returning visitor
2374
+ // is recognised (and not re-asked for their details) after a reload.
2375
+ JSON.stringify({
2376
+ ts: Date.now(),
2377
+ history: state.history,
2378
+ lead: state.pendingLead
2379
+ })
864
2380
  );
865
2381
  touchSession();
866
2382
  } catch {
867
2383
  }
868
2384
  }
869
- function loadHistory() {
2385
+ function loadPersisted() {
2386
+ const empty = { history: [], lead: void 0 };
870
2387
  try {
871
2388
  const raw = window.localStorage.getItem(historyKey);
872
- if (!raw) return [];
2389
+ if (!raw) return empty;
873
2390
  const o = JSON.parse(raw);
874
- if (!o || !Array.isArray(o.history)) return [];
2391
+ if (!o || !Array.isArray(o.history)) return empty;
875
2392
  if (Date.now() - (o.ts || 0) > SESSION_RETENTION_MS) {
876
2393
  window.localStorage.removeItem(historyKey);
877
- return [];
2394
+ return empty;
878
2395
  }
879
- return o.history;
2396
+ return {
2397
+ history: o.history,
2398
+ lead: o.lead && typeof o.lead === "object" ? o.lead : void 0
2399
+ };
880
2400
  } catch {
881
- return [];
2401
+ return empty;
882
2402
  }
883
2403
  }
884
2404
  function appendSystem(text) {
@@ -915,7 +2435,7 @@ function initChat(client, options) {
915
2435
  const lock = !state.captured;
916
2436
  panel.input.disabled = lock;
917
2437
  panel.send.disabled = lock;
918
- panel.input.placeholder = lock ? "Complete the form above to start\u2026" : "Type a message\u2026";
2438
+ panel.input.placeholder = lock ? "Complete the form above to start\u2026" : cfg.inputPlaceholder;
919
2439
  }
920
2440
  return {
921
2441
  open,
@@ -929,8 +2449,10 @@ function initChat(client, options) {
929
2449
  },
930
2450
  destroy: () => {
931
2451
  if (send.timer) window.clearTimeout(send.timer);
932
- if (send.typingTimer) window.clearTimeout(send.typingTimer);
2452
+ if (send.splitTimer) window.clearTimeout(send.splitTimer);
2453
+ if (greeting.timer) window.clearTimeout(greeting.timer);
933
2454
  window.clearInterval(refreshTimer);
2455
+ if (onDocPointerDown) document.removeEventListener("pointerdown", onDocPointerDown);
934
2456
  root.remove();
935
2457
  },
936
2458
  getSessionId: () => sessionId
@@ -954,7 +2476,7 @@ function readCaptureFields(panel) {
954
2476
  if (inputs.first_name) out.first_name = inputs.first_name.value.trim();
955
2477
  if (inputs.last_name) out.last_name = inputs.last_name.value.trim();
956
2478
  if (inputs.email) out.email = inputs.email.value.trim();
957
- if (inputs.phone) out.phone = inputs.phone.value.trim();
2479
+ if (inputs.phone) out.phone = inputs.phone.value.replace(/\D/g, "");
958
2480
  return out;
959
2481
  }
960
2482