@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/index.cjs CHANGED
@@ -93,8 +93,9 @@ async function request(cfg, req) {
93
93
  body = JSON.stringify(req.body);
94
94
  }
95
95
  const timeoutMs = req.options?.timeoutMs ?? cfg.timeoutMs;
96
+ const maxRetries = req.options?.maxRetries ?? cfg.maxRetries;
96
97
  let lastErr;
97
- for (let attempt = 0; attempt <= cfg.maxRetries; attempt++) {
98
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
98
99
  const controller = new AbortController();
99
100
  const externalSignal = req.options?.signal;
100
101
  const onAbort = () => controller.abort();
@@ -131,7 +132,7 @@ async function request(cfg, req) {
131
132
  const errBody = await safeJson(res);
132
133
  const message = pickMessage(errBody) ?? `HTTP ${res.status}`;
133
134
  const err = makeError(res.status, message, errBody, requestId);
134
- if (shouldRetry(res.status) && attempt < cfg.maxRetries) {
135
+ if (shouldRetry(res.status) && attempt < maxRetries) {
135
136
  await sleep(backoffMs(attempt, res));
136
137
  lastErr = err;
137
138
  continue;
@@ -144,7 +145,7 @@ async function request(cfg, req) {
144
145
  const isAbort = err instanceof Error && (err.name === "AbortError" || /abort/i.test(err.message));
145
146
  if (isAbort && externalSignal?.aborted) throw err;
146
147
  lastErr = err;
147
- if (attempt < cfg.maxRetries) {
148
+ if (attempt < maxRetries) {
148
149
  await sleep(backoffMs(attempt));
149
150
  continue;
150
151
  }
@@ -237,7 +238,7 @@ var NexorClient = class {
237
238
  return request(this.cfg, {
238
239
  method: "POST",
239
240
  path: `${PUBLIC_PREFIX}/leads`,
240
- body: input,
241
+ body: { ...input, skip_first_message: true },
241
242
  options
242
243
  });
243
244
  }
@@ -246,7 +247,7 @@ var NexorClient = class {
246
247
  return request(this.cfg, {
247
248
  method: "POST",
248
249
  path: `${PUBLIC_PREFIX}/leads`,
249
- body: inputs,
250
+ body: inputs.map((i) => ({ ...i, skip_first_message: true })),
250
251
  options
251
252
  });
252
253
  }
@@ -382,6 +383,23 @@ var NexorClient = class {
382
383
  });
383
384
  }
384
385
  // ─── Chat (used by initChat widget) ─────────────────────────────────────
386
+ /**
387
+ * Fetch the dashboard-authored web-chat config for a workflow. Backed by
388
+ * GET /api/public/chat/config. The widget calls this once on init to learn
389
+ * whether web chat is enabled and to get the static opening message
390
+ * (`initial_message`, a RAW template that may contain {{variables}} — the
391
+ * widget substitutes them client-side before painting the greeting).
392
+ *
393
+ * Most users will not call this directly — `initChat()` handles it.
394
+ */
395
+ getChatConfig(workflowId, options) {
396
+ return request(this.cfg, {
397
+ method: "GET",
398
+ path: `${PUBLIC_PREFIX}/chat/config`,
399
+ query: { workflow_id: workflowId },
400
+ options
401
+ });
402
+ }
385
403
  /**
386
404
  * Send a turn in a browser-embedded chat session. Backed by
387
405
  * POST /api/public/chat on the Nexor API.
@@ -396,6 +414,29 @@ var NexorClient = class {
396
414
  options
397
415
  });
398
416
  }
417
+ /**
418
+ * Create (or upsert) a lead from the web chat widget and force its FIRST
419
+ * contact to go out on a chosen channel — used when a visitor clicks
420
+ * "call me" / "email me" instead of opening the chat. Backed by
421
+ * POST /api/public/leads with `force_first_channel`.
422
+ *
423
+ * Unlike a literal `force_first_message`, `force_first_channel` pins the
424
+ * channel and lets the workflow's own agent compose the first outreach. The
425
+ * `metadata` you pass (page URL, requested channel, a note that the visitor
426
+ * asked to be contacted from the website) is stored on the lead so the agent
427
+ * has that context.
428
+ *
429
+ * Most users will not call this directly — `initChat()`'s contact-request
430
+ * form handles it.
431
+ */
432
+ requestContact(input, options) {
433
+ return request(this.cfg, {
434
+ method: "POST",
435
+ path: `${PUBLIC_PREFIX}/leads`,
436
+ body: input,
437
+ options
438
+ });
439
+ }
399
440
  };
400
441
 
401
442
  // src/chat/config.ts
@@ -404,15 +445,26 @@ function applyDefaults(o) {
404
445
  workflowId: o.workflowId,
405
446
  title: o.title ?? "Chat with us",
406
447
  subtitle: o.subtitle ?? "We typically reply in a few minutes",
448
+ openingMessage: o.openingMessage,
407
449
  greeting: o.greeting ?? "Hi there! \u{1F44B} How can we help?",
408
450
  errorText: o.errorText ?? "Sorry, something went wrong. Please try again in a moment.",
451
+ disabledText: o.disabledText ?? "Chat isn't available right now. Please reach us on another channel.",
409
452
  locale: o.locale ?? (typeof navigator !== "undefined" ? navigator.language : void 0),
410
453
  accentColor: o.accentColor ?? "#111827",
411
454
  accentTextColor: o.accentTextColor ?? "#ffffff",
412
455
  showBranding: o.showBranding ?? true,
456
+ inputPlaceholder: o.inputPlaceholder ?? "Type a message\u2026",
457
+ showRestart: o.showRestart ?? true,
458
+ restartLabel: o.restartLabel ?? "Start over",
413
459
  position: o.position ?? "bottom-right",
414
460
  openOnLoad: o.openOnLoad ?? false,
461
+ channels: resolveChannels(o.channels),
462
+ requestContact: resolveRequestContact(o.channels?.requestContact),
415
463
  debounceMs: o.debounceMs ?? 700,
464
+ splitReplies: o.splitReplies ?? true,
465
+ splitDelayMs: o.splitDelayMs ?? 0,
466
+ // 0 = auto (random 1000–1500ms per bubble)
467
+ requestTimeoutMs: o.requestTimeoutMs ?? 6e4,
416
468
  container: o.container,
417
469
  systemPrompt: o.systemPrompt,
418
470
  clientPrompt: o.clientPrompt,
@@ -420,10 +472,22 @@ function applyDefaults(o) {
420
472
  capture: {
421
473
  fields: o.capture?.fields ?? ["first_name", "email"],
422
474
  mode: o.capture?.mode ?? "before",
475
+ createLeadOnSubmit: o.capture?.createLeadOnSubmit ?? false,
423
476
  label: o.capture?.label ?? "Tell us a bit about you to get started:",
424
- submitLabel: o.capture?.submitLabel ?? "Start chat"
477
+ submitLabel: o.capture?.submitLabel ?? "Start chat",
478
+ placeholders: o.capture?.placeholders ?? {},
479
+ phoneCountryCode: o.capture?.phoneCountryCode ?? "",
480
+ phoneCountries: o.capture?.phoneCountries ?? [],
481
+ errorMessages: {
482
+ required: o.capture?.errorMessages?.required ?? "Please complete this field.",
483
+ email: o.capture?.errorMessages?.email ?? "Enter a valid email address.",
484
+ phone: o.capture?.errorMessages?.phone ?? "Enter a valid phone number, including the country code."
485
+ }
425
486
  },
426
487
  metadata: o.metadata,
488
+ resumeText: o.resumeText ?? "Continuing your conversation",
489
+ resumeWithNameText: o.resumeWithNameText ?? "Continuing as {name}",
490
+ startOverText: o.startOverText ?? "Start over",
427
491
  onOpen: o.onOpen,
428
492
  onClose: o.onClose,
429
493
  onMessage: o.onMessage,
@@ -431,6 +495,126 @@ function applyDefaults(o) {
431
495
  onError: o.onError
432
496
  };
433
497
  }
498
+ var DEFAULT_CHANNEL_ORDER = [
499
+ "whatsapp",
500
+ "call",
501
+ "email",
502
+ "instagram",
503
+ "chat"
504
+ ];
505
+ var DEFAULT_CHANNEL_LABELS = {
506
+ whatsapp: "WhatsApp",
507
+ call: "Call",
508
+ email: "Email",
509
+ instagram: "Instagram",
510
+ chat: "Chat"
511
+ };
512
+ function resolveChannels(c) {
513
+ if (!c) return [];
514
+ const order = c.order ?? DEFAULT_CHANNEL_ORDER;
515
+ const label = (k) => c.labels?.[k] ?? DEFAULT_CHANNEL_LABELS[k];
516
+ const contactChannels = new Set(
517
+ c.requestContact ? c.requestContact.channels ?? ["call", "email"] : []
518
+ );
519
+ const contactBtn = (key) => ({
520
+ key,
521
+ href: null,
522
+ external: false,
523
+ action: "contact",
524
+ label: label(key)
525
+ });
526
+ const seen = /* @__PURE__ */ new Set();
527
+ const out = [];
528
+ for (const key of order) {
529
+ if (seen.has(key)) continue;
530
+ seen.add(key);
531
+ switch (key) {
532
+ case "whatsapp": {
533
+ if (contactChannels.has("whatsapp")) {
534
+ out.push(contactBtn(key));
535
+ break;
536
+ }
537
+ if (!c.whatsapp) break;
538
+ const digits = c.whatsapp.replace(/[^\d]/g, "");
539
+ if (!digits) break;
540
+ const qs = c.whatsappText ? `?text=${encodeURIComponent(c.whatsappText)}` : "";
541
+ out.push({ key, href: `https://wa.me/${digits}${qs}`, external: true, label: label(key) });
542
+ break;
543
+ }
544
+ case "call": {
545
+ if (contactChannels.has("call")) {
546
+ out.push(contactBtn(key));
547
+ break;
548
+ }
549
+ if (!c.call) break;
550
+ const tel = c.call.replace(/[^\d+]/g, "");
551
+ if (!tel) break;
552
+ out.push({ key, href: `tel:${tel}`, external: false, label: label(key) });
553
+ break;
554
+ }
555
+ case "email": {
556
+ if (contactChannels.has("email")) {
557
+ out.push(contactBtn(key));
558
+ break;
559
+ }
560
+ if (!c.email) break;
561
+ out.push({ key, href: `mailto:${c.email}`, external: false, label: label(key) });
562
+ break;
563
+ }
564
+ case "instagram": {
565
+ if (!c.instagram) break;
566
+ const href = /^https?:\/\//i.test(c.instagram) ? c.instagram : `https://instagram.com/${c.instagram.replace(/^@/, "")}`;
567
+ out.push({ key, href, external: true, label: label(key) });
568
+ break;
569
+ }
570
+ case "chat": {
571
+ if (c.chat === false) break;
572
+ out.push({ key, href: null, external: false, label: label(key) });
573
+ break;
574
+ }
575
+ }
576
+ }
577
+ if (out.every((ch) => ch.key === "chat")) return [];
578
+ return out;
579
+ }
580
+ var DEFAULT_CONTACT_TITLE = {
581
+ call: "Request a call",
582
+ email: "Request an email",
583
+ whatsapp: "Request a message"
584
+ };
585
+ var DEFAULT_CONTACT_SUBTITLE = {
586
+ call: "Leave your details and we'll call you. We need this to reach you.",
587
+ email: "Leave your details and we'll email you. We need this to reach you.",
588
+ whatsapp: "Leave your details and we'll message you. We need this to reach you."
589
+ };
590
+ var DEFAULT_CONTACT_SUBMIT = {
591
+ call: "Request call",
592
+ email: "Request email",
593
+ whatsapp: "Request message"
594
+ };
595
+ var DEFAULT_CONTACT_SUCCESS = {
596
+ call: "Thanks! We'll call you shortly.",
597
+ email: "Thanks! We'll email you shortly.",
598
+ whatsapp: "Thanks! We'll message you shortly."
599
+ };
600
+ function resolveRequestContact(rc) {
601
+ if (!rc) return void 0;
602
+ const channels = rc.channels && rc.channels.length ? rc.channels : ["call", "email"];
603
+ const pick = (over, def) => {
604
+ const out = {};
605
+ for (const ch of channels) out[ch] = over?.[ch] ?? def[ch];
606
+ return out;
607
+ };
608
+ return {
609
+ channels,
610
+ title: pick(rc.title, DEFAULT_CONTACT_TITLE),
611
+ subtitle: pick(rc.subtitle, DEFAULT_CONTACT_SUBTITLE),
612
+ submitLabel: pick(rc.submitLabel, DEFAULT_CONTACT_SUBMIT),
613
+ successText: pick(rc.successText, DEFAULT_CONTACT_SUCCESS),
614
+ doneLabel: rc.doneLabel ?? "Done",
615
+ errorText: rc.errorText ?? "Something went wrong. Please try again."
616
+ };
617
+ }
434
618
  function needsCapture(cfg) {
435
619
  if (cfg.capture.mode === "skip") return false;
436
620
  if (cfg.lead) {
@@ -491,6 +675,95 @@ function randomHex(bytes) {
491
675
  return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
492
676
  }
493
677
 
678
+ // src/chat/validate.ts
679
+ function isValidEmail(value) {
680
+ const s = (value || "").trim();
681
+ if (s.length < 6 || s.length > 254) return false;
682
+ return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s);
683
+ }
684
+
685
+ // src/chat/template.ts
686
+ var ALIASES = {
687
+ first_name: "first_name",
688
+ name: "first_name",
689
+ lead_first_name: "first_name",
690
+ last_name: "last_name",
691
+ lastname: "last_name",
692
+ lead_last_name: "last_name",
693
+ full_name: "full_name",
694
+ fullname: "full_name",
695
+ email: "email",
696
+ phone: "phone"
697
+ };
698
+ function clean(v) {
699
+ return v == null ? "" : String(v).trim();
700
+ }
701
+ function leadValue(lead, canonical) {
702
+ if (!lead) return "";
703
+ switch (canonical) {
704
+ case "first_name":
705
+ return clean(lead.first_name);
706
+ case "last_name":
707
+ return clean(lead.last_name);
708
+ case "full_name":
709
+ return [clean(lead.first_name), clean(lead.last_name)].filter(Boolean).join(" ");
710
+ case "email":
711
+ return clean(lead.email);
712
+ case "phone":
713
+ return clean(lead.phone);
714
+ default:
715
+ return "";
716
+ }
717
+ }
718
+ function renderLeadTemplate(template, lead) {
719
+ if (typeof template !== "string" || !template) return "";
720
+ const replaced = template.replace(/\{\{\s*([a-zA-Z_]+)\s*\}\}/g, (match, raw) => {
721
+ const canonical = ALIASES[raw.toLowerCase()];
722
+ if (!canonical) return match;
723
+ return leadValue(lead, canonical);
724
+ });
725
+ return replaced.replace(/[ \t]+([,.!?;:])/g, "$1").replace(/[ \t]{2,}/g, " ").replace(/^[ \t]*[,;:][ \t]*/gm, "").replace(/[ \t]+$/gm, "").trim();
726
+ }
727
+
728
+ // src/chat/countries.ts
729
+ var FALLBACK_COUNTRY = {
730
+ iso: "CL",
731
+ name: "Chile",
732
+ dial: "+56",
733
+ flag: "\u{1F1E8}\u{1F1F1}",
734
+ nsn: [9, 9]
735
+ };
736
+ var COUNTRIES = [
737
+ FALLBACK_COUNTRY,
738
+ { iso: "MX", name: "M\xE9xico", dial: "+52", flag: "\u{1F1F2}\u{1F1FD}", nsn: [10, 10] },
739
+ { iso: "AR", name: "Argentina", dial: "+54", flag: "\u{1F1E6}\u{1F1F7}", nsn: [10, 11] },
740
+ { iso: "CO", name: "Colombia", dial: "+57", flag: "\u{1F1E8}\u{1F1F4}", nsn: [10, 10] },
741
+ { iso: "PE", name: "Per\xFA", dial: "+51", flag: "\u{1F1F5}\u{1F1EA}", nsn: [9, 9] },
742
+ { iso: "BR", name: "Brasil", dial: "+55", flag: "\u{1F1E7}\u{1F1F7}", nsn: [10, 11] },
743
+ { iso: "EC", name: "Ecuador", dial: "+593", flag: "\u{1F1EA}\u{1F1E8}", nsn: [8, 9] },
744
+ { iso: "UY", name: "Uruguay", dial: "+598", flag: "\u{1F1FA}\u{1F1FE}", nsn: [8, 8] },
745
+ { iso: "US", name: "United States", dial: "+1", flag: "\u{1F1FA}\u{1F1F8}", nsn: [10, 10] },
746
+ { iso: "ES", name: "Espa\xF1a", dial: "+34", flag: "\u{1F1EA}\u{1F1F8}", nsn: [9, 9] },
747
+ { iso: "GB", name: "United Kingdom", dial: "+44", flag: "\u{1F1EC}\u{1F1E7}", nsn: [10, 10] }
748
+ ];
749
+ function maxNsn(c) {
750
+ return c.nsn[1];
751
+ }
752
+ function countryByIso(iso) {
753
+ if (!iso) return void 0;
754
+ const up = iso.toUpperCase();
755
+ return COUNTRIES.find((c) => c.iso === up);
756
+ }
757
+ function countryByDial(dial) {
758
+ if (!dial) return void 0;
759
+ const norm = dial.startsWith("+") ? dial : `+${dial.replace(/^\+?/, "")}`;
760
+ return COUNTRIES.find((c) => c.dial === norm);
761
+ }
762
+ function resolveDefaultCountry(codeOrIso) {
763
+ const v = (codeOrIso || "").trim();
764
+ return countryByDial(v) || countryByIso(v) || FALLBACK_COUNTRY;
765
+ }
766
+
494
767
  // src/chat/styles.ts
495
768
  var widgetCss = (accent, accentText) => `
496
769
  .nexor-chat,
@@ -514,6 +787,12 @@ var widgetCss = (accent, accentText) => `
514
787
  left: 20px;
515
788
  }
516
789
 
790
+ /* \u2500\u2500 Launcher wrapper (anchors the optional speed-dial fan) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
791
+ .nexor-chat__launcher-wrap {
792
+ position: relative;
793
+ display: inline-flex;
794
+ }
795
+
517
796
  /* \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 */
518
797
  .nexor-chat__launcher {
519
798
  width: 56px;
@@ -550,6 +829,12 @@ var widgetCss = (accent, accentText) => `
550
829
  transform: scale(0.85);
551
830
  opacity: 0.75;
552
831
  }
832
+ /* While the chat panel is open the launcher is dormant: no hover fan, no
833
+ clicks. The visitor closes the chat (header \u2715) to reach the channels again.
834
+ pointer-events:none also stops the CSS :hover that would reveal the fan. */
835
+ .nexor-chat[data-open="true"] .nexor-chat__launcher-wrap {
836
+ pointer-events: none;
837
+ }
553
838
  .nexor-chat__launcher svg { width: 24px; height: 24px; transition: transform 200ms ease; }
554
839
  .nexor-chat[data-open="true"] .nexor-chat__launcher svg { transform: rotate(8deg); }
555
840
 
@@ -581,6 +866,118 @@ var widgetCss = (accent, accentText) => `
581
866
  to { transform: scale(1); }
582
867
  }
583
868
 
869
+ /* \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 */
870
+ /* Sits above the launcher, out of normal flow so it never shifts the bubble.
871
+ Hidden until the wrapper is hovered (pointer devices) or tapped open
872
+ (data-expanded, set by JS \u2014 the only path on touch). */
873
+ .nexor-chat__channels {
874
+ position: absolute;
875
+ /* Anchor the fan's box to the top of the launcher and push the buttons up
876
+ with padding instead of a gap. This keeps the launcher + fan a single,
877
+ continuous hover surface \u2014 moving the cursor from the bubble up to the
878
+ buttons never crosses dead space, so the drawer no longer snaps shut. */
879
+ bottom: 100%;
880
+ right: 0;
881
+ padding-bottom: 14px;
882
+ display: flex;
883
+ flex-direction: column;
884
+ gap: 12px;
885
+ align-items: flex-end;
886
+ opacity: 0;
887
+ visibility: hidden;
888
+ pointer-events: none;
889
+ transform: translateY(10px);
890
+ transition: opacity 180ms ease, transform 180ms ease, visibility 180ms;
891
+ }
892
+ .nexor-chat[data-position="bottom-left"] .nexor-chat__channels {
893
+ right: auto;
894
+ left: 0;
895
+ align-items: flex-start;
896
+ }
897
+
898
+ @media (hover: hover) and (pointer: fine) {
899
+ .nexor-chat__launcher-wrap:hover .nexor-chat__channels {
900
+ opacity: 1;
901
+ visibility: visible;
902
+ pointer-events: auto;
903
+ transform: translateY(0);
904
+ }
905
+ }
906
+ .nexor-chat__launcher-wrap[data-expanded="true"] .nexor-chat__channels {
907
+ opacity: 1;
908
+ visibility: visible;
909
+ pointer-events: auto;
910
+ transform: translateY(0);
911
+ }
912
+
913
+ /* Subtle rise-and-stagger as each item appears (nearest the launcher first). */
914
+ .nexor-chat__channel-item {
915
+ display: flex;
916
+ align-items: center;
917
+ gap: 10px;
918
+ transform: translateY(8px);
919
+ opacity: 0;
920
+ transition: transform 200ms cubic-bezier(.2,.7,.2,1.1), opacity 200ms ease;
921
+ }
922
+ .nexor-chat[data-position="bottom-left"] .nexor-chat__channel-item {
923
+ flex-direction: row-reverse;
924
+ }
925
+ .nexor-chat__launcher-wrap[data-expanded="true"] .nexor-chat__channel-item,
926
+ .nexor-chat__launcher-wrap:hover .nexor-chat__channel-item {
927
+ transform: translateY(0);
928
+ opacity: 1;
929
+ }
930
+ .nexor-chat__channel-item:nth-last-child(1) { transition-delay: 0ms; }
931
+ .nexor-chat__channel-item:nth-last-child(2) { transition-delay: 40ms; }
932
+ .nexor-chat__channel-item:nth-last-child(3) { transition-delay: 80ms; }
933
+ .nexor-chat__channel-item:nth-last-child(4) { transition-delay: 120ms; }
934
+ .nexor-chat__channel-item:nth-last-child(5) { transition-delay: 160ms; }
935
+
936
+ /* The round mini-button for each channel. */
937
+ .nexor-chat__channel {
938
+ width: 46px;
939
+ height: 46px;
940
+ border-radius: 9999px;
941
+ border: none;
942
+ cursor: pointer;
943
+ display: flex;
944
+ align-items: center;
945
+ justify-content: center;
946
+ padding: 0;
947
+ background: ${accent};
948
+ color: ${accentText};
949
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
950
+ text-decoration: none;
951
+ transition: transform 140ms cubic-bezier(.2,.7,.2,1.1), box-shadow 140ms ease, filter 140ms ease;
952
+ }
953
+ .nexor-chat__channel:hover {
954
+ transform: scale(1.08);
955
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.26);
956
+ filter: brightness(1.05);
957
+ }
958
+ .nexor-chat__channel:active { transform: scale(0.94); }
959
+ .nexor-chat__channel:focus-visible { outline: 2px solid ${accent}; outline-offset: 2px; }
960
+ .nexor-chat__channel svg { width: 24px; height: 24px; display: block; }
961
+
962
+ /* Brand colours win over the accent where a channel has a recognisable hue. */
963
+ .nexor-chat__channel--whatsapp { background: #25d366; color: #fff; }
964
+ .nexor-chat__channel--instagram {
965
+ background: radial-gradient(circle at 30% 110%, #fdf497 0%, #fd5949 45%, #d6249f 70%, #285aeb 100%);
966
+ color: #fff;
967
+ }
968
+
969
+ /* Label pill beside each button. */
970
+ .nexor-chat__channel-label {
971
+ background: #fff;
972
+ color: #111;
973
+ font-size: 12px;
974
+ font-weight: 600;
975
+ padding: 5px 10px;
976
+ border-radius: 8px;
977
+ white-space: nowrap;
978
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
979
+ }
980
+
584
981
  /* \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 */
585
982
  .nexor-chat__panel {
586
983
  position: absolute;
@@ -634,6 +1031,7 @@ var widgetCss = (accent, accentText) => `
634
1031
  font-size: 12px;
635
1032
  opacity: 0.85;
636
1033
  margin: 2px 0 0;
1034
+ line-height: 1.4;
637
1035
  }
638
1036
  .nexor-chat__status-dot {
639
1037
  display: inline-block;
@@ -663,6 +1061,23 @@ var widgetCss = (accent, accentText) => `
663
1061
  .nexor-chat__close:hover { background: rgba(255,255,255,0.18); opacity: 1; }
664
1062
  .nexor-chat__close:active { transform: scale(0.92); }
665
1063
  .nexor-chat__close svg { width: 18px; height: 18px; display: block; }
1064
+ /* Keep the title left and group the action buttons on the right. */
1065
+ .nexor-chat__header > div { margin-right: auto; min-width: 0; }
1066
+ .nexor-chat__restart {
1067
+ background: transparent;
1068
+ border: none;
1069
+ color: inherit;
1070
+ cursor: pointer;
1071
+ padding: 4px;
1072
+ border-radius: 6px;
1073
+ opacity: 0.7;
1074
+ transition: background 140ms ease, transform 200ms ease, opacity 140ms ease;
1075
+ }
1076
+ .nexor-chat__restart:hover { background: rgba(255,255,255,0.18); opacity: 1; }
1077
+ .nexor-chat__restart:active { transform: rotate(-90deg) scale(0.9); }
1078
+ .nexor-chat__restart svg { width: 17px; height: 17px; display: block; }
1079
+ /* Nothing to reset on the capture screen \u2014 hide it there. */
1080
+ .nexor-chat__panel[data-view="capture"] .nexor-chat__restart { display: none; }
666
1081
 
667
1082
  /* \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 */
668
1083
  .nexor-chat__body {
@@ -762,6 +1177,72 @@ var widgetCss = (accent, accentText) => `
762
1177
  30% { opacity: 1; transform: translateY(-3px); }
763
1178
  }
764
1179
 
1180
+ /* \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 */
1181
+ .nexor-chat__resume {
1182
+ display: flex;
1183
+ align-items: center;
1184
+ justify-content: space-between;
1185
+ gap: 10px;
1186
+ background: #fff;
1187
+ border: 1px solid rgba(0,0,0,0.06);
1188
+ border-radius: 10px;
1189
+ padding: 8px 12px;
1190
+ margin-bottom: 4px;
1191
+ box-shadow: 0 1px 2px rgba(0,0,0,0.04);
1192
+ animation: nexor-fade-in 240ms ease both;
1193
+ }
1194
+ .nexor-chat__resume-text {
1195
+ font-size: 12px;
1196
+ color: #6b7280;
1197
+ min-width: 0;
1198
+ overflow: hidden;
1199
+ text-overflow: ellipsis;
1200
+ white-space: nowrap;
1201
+ }
1202
+ .nexor-chat__resume-reset {
1203
+ flex: none;
1204
+ background: transparent;
1205
+ border: none;
1206
+ color: ${accent};
1207
+ font: inherit;
1208
+ font-size: 12px;
1209
+ font-weight: 600;
1210
+ cursor: pointer;
1211
+ padding: 2px 4px;
1212
+ border-radius: 6px;
1213
+ text-decoration: underline;
1214
+ text-underline-offset: 2px;
1215
+ transition: opacity 140ms ease;
1216
+ }
1217
+ .nexor-chat__resume-reset:hover { opacity: 0.7; }
1218
+ .nexor-chat__resume-reset:active { opacity: 0.5; }
1219
+
1220
+ /* \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 */
1221
+ /* The form is a distinct screen, not a card wedged into the message list.
1222
+ data-view swaps between the capture screen and the chat (body + composer). */
1223
+ .nexor-chat__capture-view {
1224
+ min-height: 0;
1225
+ overflow-y: auto;
1226
+ padding: 16px;
1227
+ background: #f6f7f9;
1228
+ display: flex;
1229
+ flex-direction: column;
1230
+ animation: nexor-fade-in 200ms ease both;
1231
+ }
1232
+ /* In the form view the panel hugs the form \u2014 no fixed 540px height leaving
1233
+ empty gaps above and below the card. It still can't exceed max-height. */
1234
+ .nexor-chat__panel[data-view="capture"] {
1235
+ height: auto;
1236
+ }
1237
+ .nexor-chat__panel[data-view="chat"] .nexor-chat__capture-view { display: none; }
1238
+ .nexor-chat__panel[data-view="capture"] .nexor-chat__body,
1239
+ .nexor-chat__panel[data-view="capture"] .nexor-chat__composer { display: none; }
1240
+ .nexor-chat__capture-view .nexor-chat__capture-label {
1241
+ font-size: 13px;
1242
+ font-weight: 600;
1243
+ color: #374151;
1244
+ }
1245
+
765
1246
  /* \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 */
766
1247
  .nexor-chat__capture {
767
1248
  background: #fff;
@@ -797,11 +1278,72 @@ var widgetCss = (accent, accentText) => `
797
1278
  width: 100%;
798
1279
  transition: border-color 140ms ease, box-shadow 140ms ease;
799
1280
  }
1281
+ /* Composer textarea: auto-grows (height managed in JS) up to ~4 lines, then
1282
+ scrolls. resize:none disables the manual drag handle. */
1283
+ textarea.nexor-chat__input {
1284
+ display: block;
1285
+ resize: none;
1286
+ overflow-y: auto;
1287
+ line-height: 20px;
1288
+ max-height: 98px; /* 4 lines (20\xD74) + 16 padding + 2 border */
1289
+ white-space: pre-wrap;
1290
+ word-break: break-word;
1291
+ }
800
1292
  .nexor-chat__input:focus,
801
1293
  .nexor-chat__capture input:focus {
802
1294
  border-color: ${accent};
803
1295
  box-shadow: 0 0 0 3px ${accent}33;
804
1296
  }
1297
+ .nexor-chat__input--error,
1298
+ .nexor-chat__capture input.nexor-chat__input--error {
1299
+ border-color: #ef4444;
1300
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.16);
1301
+ }
1302
+ .nexor-chat__capture-error {
1303
+ font-size: 12px;
1304
+ color: #ef4444;
1305
+ margin: 0;
1306
+ }
1307
+ .nexor-chat__capture-error[hidden] { display: none; }
1308
+
1309
+ /* \u2500\u2500 Phone field (flag + dial code selector joined to a digit input) \u2500\u2500 */
1310
+ .nexor-chat__phone {
1311
+ display: flex;
1312
+ align-items: stretch;
1313
+ width: 100%;
1314
+ border: 1px solid #d1d5db;
1315
+ border-radius: 8px;
1316
+ background: #fff;
1317
+ overflow: hidden;
1318
+ transition: border-color 140ms ease, box-shadow 140ms ease;
1319
+ }
1320
+ .nexor-chat__phone:focus-within {
1321
+ border-color: ${accent};
1322
+ box-shadow: 0 0 0 3px ${accent}33;
1323
+ }
1324
+ .nexor-chat__phone.nexor-chat__input--error {
1325
+ border-color: #ef4444;
1326
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.16);
1327
+ }
1328
+ .nexor-chat__phone-country {
1329
+ flex: none;
1330
+ border: none;
1331
+ border-right: 1px solid #e5e7eb;
1332
+ background: #f9fafb;
1333
+ color: #111;
1334
+ font: inherit;
1335
+ padding: 0 12px 0 10px;
1336
+ cursor: pointer;
1337
+ outline: none;
1338
+ }
1339
+ .nexor-chat__phone-number.nexor-chat__input {
1340
+ border: none;
1341
+ border-radius: 0;
1342
+ box-shadow: none;
1343
+ flex: 1;
1344
+ min-width: 0;
1345
+ }
1346
+
805
1347
  .nexor-chat__capture-submit {
806
1348
  background: ${accent};
807
1349
  color: ${accentText};
@@ -817,6 +1359,66 @@ var widgetCss = (accent, accentText) => `
817
1359
  .nexor-chat__capture-submit:active { transform: scale(0.97); }
818
1360
  .nexor-chat__capture-submit[disabled] { opacity: 0.6; cursor: not-allowed; }
819
1361
 
1362
+ /* \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 */
1363
+ /* Its own full-panel screen, opened from a "call"/"email" fan button. Reuses
1364
+ the capture form styling (.nexor-chat__capture) for the fields. */
1365
+ .nexor-chat__contact-view {
1366
+ min-height: 0;
1367
+ overflow-y: auto;
1368
+ padding: 16px;
1369
+ background: #f6f7f9;
1370
+ display: flex;
1371
+ flex-direction: column;
1372
+ animation: nexor-fade-in 200ms ease both;
1373
+ }
1374
+ /* Show the contact view only in its own data-view; hug the form (no fixed
1375
+ height leaving gaps), but never exceed the panel's max-height. */
1376
+ .nexor-chat__panel[data-view="contact"] { height: auto; }
1377
+ .nexor-chat__panel:not([data-view="contact"]) .nexor-chat__contact-view { display: none; }
1378
+ .nexor-chat__panel[data-view="contact"] .nexor-chat__capture-view,
1379
+ .nexor-chat__panel[data-view="contact"] .nexor-chat__body,
1380
+ .nexor-chat__panel[data-view="contact"] .nexor-chat__composer { display: none; }
1381
+ .nexor-chat__panel[data-view="contact"] .nexor-chat__restart { display: none; }
1382
+
1383
+ /* The contact form lives under the header, which carries its title/explainer;
1384
+ the live-status dot ("we're listening") makes no sense for a form. */
1385
+ .nexor-chat__panel[data-view="contact"] .nexor-chat__status-dot { display: none; }
1386
+
1387
+ /* form \u2194 success swap, driven by the view's data-state */
1388
+ .nexor-chat__contact-view[data-state="sent"] .nexor-chat__contact-form { display: none; }
1389
+ .nexor-chat__contact-view:not([data-state="sent"]) .nexor-chat__contact-success { display: none; }
1390
+
1391
+ .nexor-chat__contact-success {
1392
+ display: flex;
1393
+ flex-direction: column;
1394
+ align-items: center;
1395
+ text-align: center;
1396
+ gap: 12px;
1397
+ padding: 28px 12px 12px;
1398
+ animation: nexor-msg-in 280ms cubic-bezier(.2,.7,.2,1.1) both;
1399
+ }
1400
+ .nexor-chat__contact-success-icon {
1401
+ width: 48px;
1402
+ height: 48px;
1403
+ display: flex;
1404
+ align-items: center;
1405
+ justify-content: center;
1406
+ border-radius: 50%;
1407
+ background: ${accent};
1408
+ color: ${accentText};
1409
+ }
1410
+ .nexor-chat__contact-success-icon svg { width: 26px; height: 26px; }
1411
+ .nexor-chat__contact-success-text {
1412
+ font-size: 14px;
1413
+ color: #374151;
1414
+ margin: 0;
1415
+ line-height: 1.5;
1416
+ }
1417
+ .nexor-chat__contact-success .nexor-chat__capture-submit {
1418
+ margin-top: 4px;
1419
+ align-self: stretch;
1420
+ }
1421
+
820
1422
  /* \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 */
821
1423
  .nexor-chat__composer {
822
1424
  display: flex;
@@ -824,7 +1426,8 @@ var widgetCss = (accent, accentText) => `
824
1426
  padding: 10px;
825
1427
  background: #fff;
826
1428
  border-top: 1px solid rgba(0,0,0,0.06);
827
- align-items: stretch;
1429
+ /* Pin the send button to the bottom so it stays put as the textarea grows. */
1430
+ align-items: flex-end;
828
1431
  }
829
1432
  .nexor-chat__send {
830
1433
  background: ${accent};
@@ -836,6 +1439,8 @@ var widgetCss = (accent, accentText) => `
836
1439
  font-weight: 600;
837
1440
  cursor: pointer;
838
1441
  min-width: 44px;
1442
+ height: 38px; /* matches the one-line textarea height */
1443
+ flex: none;
839
1444
  display: flex;
840
1445
  align-items: center;
841
1446
  justify-content: center;
@@ -871,7 +1476,10 @@ var widgetCss = (accent, accentText) => `
871
1476
  }
872
1477
 
873
1478
  .nexor-chat__footer {
874
- text-align: center;
1479
+ display: flex;
1480
+ align-items: center;
1481
+ justify-content: center;
1482
+ gap: 5px;
875
1483
  font-size: 11px;
876
1484
  color: #9ca3af;
877
1485
  padding: 6px;
@@ -883,7 +1491,111 @@ var widgetCss = (accent, accentText) => `
883
1491
  text-decoration: none;
884
1492
  font-weight: 600;
885
1493
  }
886
- .nexor-chat__footer a:hover { text-decoration: underline; }
1494
+ /* "Powered by" + the Nexor wordmark (same mark as the site navbar). Plain
1495
+ attribution \u2014 not a link, but hovering shows a getnexor.ai tooltip. */
1496
+ .nexor-chat__brand {
1497
+ position: relative;
1498
+ display: inline-flex;
1499
+ align-items: center;
1500
+ cursor: help; /* hover reveals the getnexor.ai tooltip; not a link */
1501
+ }
1502
+ .nexor-chat__brand-logo {
1503
+ height: 13px;
1504
+ width: auto;
1505
+ display: block;
1506
+ }
1507
+ /* Custom tooltip (instant + reliable across browsers, unlike a native SVG
1508
+ <title>). Sits above the logo and fades in on hover/focus. */
1509
+ .nexor-chat__brand-tip {
1510
+ position: absolute;
1511
+ bottom: calc(100% + 7px);
1512
+ left: 50%;
1513
+ transform: translateX(-50%) translateY(4px);
1514
+ background: #111827;
1515
+ color: #fff;
1516
+ font-size: 11px;
1517
+ font-weight: 500;
1518
+ line-height: 1;
1519
+ letter-spacing: 0.01em;
1520
+ padding: 5px 8px;
1521
+ border-radius: 6px;
1522
+ white-space: nowrap;
1523
+ opacity: 0;
1524
+ pointer-events: none;
1525
+ box-shadow: 0 4px 12px rgba(0,0,0,0.18);
1526
+ transition: opacity 140ms ease, transform 140ms ease;
1527
+ z-index: 5;
1528
+ }
1529
+ .nexor-chat__brand-tip::after {
1530
+ content: "";
1531
+ position: absolute;
1532
+ top: 100%;
1533
+ left: 50%;
1534
+ transform: translateX(-50%);
1535
+ border: 4px solid transparent;
1536
+ border-top-color: #111827;
1537
+ }
1538
+ .nexor-chat__brand:hover .nexor-chat__brand-tip {
1539
+ opacity: 1;
1540
+ transform: translateX(-50%) translateY(0);
1541
+ }
1542
+
1543
+ /* \u2500\u2500 Mobile: full-screen panel with an obvious close affordance \u2500\u2500\u2500\u2500\u2500 */
1544
+ @media (max-width: 480px) {
1545
+ /* The panel takes over the whole viewport so the keyboard + messages have
1546
+ room. 100dvh tracks the dynamic viewport as mobile browser chrome shows/
1547
+ hides, avoiding a cut-off composer. */
1548
+ .nexor-chat__panel {
1549
+ position: fixed;
1550
+ inset: 0;
1551
+ top: 0;
1552
+ left: 0;
1553
+ right: 0;
1554
+ bottom: 0;
1555
+ width: 100%;
1556
+ max-width: 100%;
1557
+ height: 100vh;
1558
+ height: 100dvh;
1559
+ max-height: none;
1560
+ border-radius: 0;
1561
+ border: none;
1562
+ transform: translateY(100%);
1563
+ }
1564
+ .nexor-chat[data-position="bottom-left"] .nexor-chat__panel { left: 0; }
1565
+ .nexor-chat[data-open="true"] .nexor-chat__panel { transform: translateY(0); }
1566
+ /* Keep the form view full-screen on phones too (overrides the desktop
1567
+ hug-the-form height). */
1568
+ .nexor-chat__panel[data-view="capture"] {
1569
+ height: 100vh;
1570
+ height: 100dvh;
1571
+ }
1572
+ .nexor-chat__panel[data-view="capture"] .nexor-chat__capture-view {
1573
+ justify-content: center;
1574
+ }
1575
+
1576
+ /* Hide the launcher (and any open fan) while the full-screen panel is up \u2014
1577
+ the in-header close button is the way back out. */
1578
+ .nexor-chat[data-open="true"] .nexor-chat__launcher-wrap {
1579
+ opacity: 0;
1580
+ pointer-events: none;
1581
+ }
1582
+
1583
+ /* Roomier header + a bigger, unmistakable close target on touch. */
1584
+ .nexor-chat__header {
1585
+ padding: 16px 14px 16px 18px;
1586
+ padding-top: max(16px, env(safe-area-inset-top));
1587
+ }
1588
+ .nexor-chat__close {
1589
+ padding: 10px;
1590
+ background: rgba(255, 255, 255, 0.16);
1591
+ }
1592
+ .nexor-chat__close svg { width: 24px; height: 24px; }
1593
+
1594
+ /* Keep the composer clear of the home-indicator / gesture bar. */
1595
+ .nexor-chat__composer {
1596
+ padding-bottom: max(10px, env(safe-area-inset-bottom));
1597
+ }
1598
+ }
887
1599
 
888
1600
  /* Respect users who request reduced motion. */
889
1601
  @media (prefers-reduced-motion: reduce) {
@@ -905,50 +1617,175 @@ function injectStyles(accent, accentText) {
905
1617
  style.textContent = widgetCss(accent, accentText);
906
1618
  document.head.appendChild(style);
907
1619
  }
908
- function buildLauncher() {
1620
+ var CHAT_ICON = `
1621
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1622
+ <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"
1623
+ stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
1624
+ </svg>`;
1625
+ var CHANNEL_ICONS = {
1626
+ whatsapp: `
1627
+ <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1628
+ <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"/>
1629
+ </svg>`,
1630
+ call: `
1631
+ <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1632
+ <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"/>
1633
+ </svg>`,
1634
+ email: `
1635
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1636
+ <rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8"/>
1637
+ <path d="m4 7 8 6 8-6" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
1638
+ </svg>`,
1639
+ instagram: `
1640
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1641
+ <rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke="currentColor" stroke-width="1.8"/>
1642
+ <circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/>
1643
+ <circle cx="17.2" cy="6.8" r="1.2" fill="currentColor"/>
1644
+ </svg>`,
1645
+ chat: CHAT_ICON
1646
+ };
1647
+ function buildLauncher(cfg) {
1648
+ const wrap = document.createElement("div");
1649
+ wrap.className = "nexor-chat__launcher-wrap";
1650
+ const hasFan = cfg.channels.length > 0;
1651
+ const { fan, channelButtons } = hasFan ? buildChannelFan(cfg.channels) : { fan: null, channelButtons: [] };
1652
+ if (fan) wrap.appendChild(fan);
909
1653
  const el = document.createElement("button");
910
1654
  el.type = "button";
911
1655
  el.className = "nexor-chat__launcher";
912
- el.setAttribute("aria-label", "Open chat");
913
- el.innerHTML = `
914
- <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
915
- <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"
916
- stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
917
- </svg>
918
- `;
1656
+ el.setAttribute("aria-label", hasFan ? "Contact us" : "Open chat");
1657
+ if (hasFan) {
1658
+ el.setAttribute("aria-haspopup", "true");
1659
+ el.setAttribute("aria-expanded", "false");
1660
+ }
1661
+ el.innerHTML = CHAT_ICON;
919
1662
  const badge = document.createElement("span");
920
1663
  badge.className = "nexor-chat__unread";
921
1664
  badge.style.display = "none";
922
1665
  el.appendChild(badge);
923
- return { el, badge };
1666
+ wrap.appendChild(el);
1667
+ return { wrap, el, badge, fan, channelButtons };
1668
+ }
1669
+ function buildChannelFan(channels) {
1670
+ const fan = document.createElement("div");
1671
+ fan.className = "nexor-chat__channels";
1672
+ fan.setAttribute("role", "menu");
1673
+ const channelButtons = [];
1674
+ for (const ch of channels) {
1675
+ const item = document.createElement("div");
1676
+ item.className = "nexor-chat__channel-item";
1677
+ const labelEl = document.createElement("span");
1678
+ labelEl.className = "nexor-chat__channel-label";
1679
+ labelEl.textContent = ch.label;
1680
+ let el;
1681
+ if (ch.href === null) {
1682
+ const btn = document.createElement("button");
1683
+ btn.type = "button";
1684
+ el = btn;
1685
+ } else {
1686
+ const a = document.createElement("a");
1687
+ a.href = ch.href;
1688
+ if (ch.external) {
1689
+ a.target = "_blank";
1690
+ a.rel = "noopener noreferrer";
1691
+ }
1692
+ el = a;
1693
+ }
1694
+ el.className = `nexor-chat__channel nexor-chat__channel--${ch.key}`;
1695
+ el.setAttribute("role", "menuitem");
1696
+ el.setAttribute("aria-label", ch.label);
1697
+ el.innerHTML = CHANNEL_ICONS[ch.key];
1698
+ item.appendChild(labelEl);
1699
+ item.appendChild(el);
1700
+ fan.appendChild(item);
1701
+ channelButtons.push({ key: ch.key, el, action: ch.action });
1702
+ }
1703
+ return { fan, channelButtons };
924
1704
  }
925
1705
  function buildPanel(cfg) {
926
1706
  const el = document.createElement("div");
927
1707
  el.className = "nexor-chat__panel";
928
1708
  el.setAttribute("role", "dialog");
929
1709
  el.setAttribute("aria-label", cfg.title);
930
- const { header, closeBtn } = buildHeader(cfg);
931
- const { body, captureForm, captureInputs } = buildBody(cfg);
932
- const { form, input, send } = buildComposer();
1710
+ const { header, closeBtn, restartBtn, titleEl, subtitleEl, subtitleTextEl } = buildHeader(cfg);
1711
+ const body = buildBody();
1712
+ const { form, input, send } = buildComposer(cfg);
933
1713
  const footer = cfg.showBranding ? buildFooter() : null;
1714
+ const captureInputs = {};
1715
+ let captureView = null;
1716
+ let captureForm = null;
1717
+ let captureError = null;
1718
+ let phoneCountry = null;
1719
+ if (needsCapture(cfg)) {
1720
+ const built = buildCaptureView(cfg, captureInputs);
1721
+ captureView = built.view;
1722
+ captureForm = built.form;
1723
+ captureError = built.error;
1724
+ phoneCountry = built.phoneCountry;
1725
+ }
1726
+ const contact = cfg.requestContact ? buildContactView(cfg) : null;
1727
+ el.setAttribute("data-view", captureView ? "capture" : "chat");
934
1728
  el.appendChild(header);
1729
+ if (captureView) el.appendChild(captureView);
1730
+ if (contact) el.appendChild(contact.view);
935
1731
  el.appendChild(body);
936
1732
  el.appendChild(form);
937
1733
  if (footer) el.appendChild(footer);
938
- return { el, closeBtn, body, form, input, send, captureForm, captureInputs };
1734
+ return {
1735
+ el,
1736
+ closeBtn,
1737
+ restartBtn,
1738
+ headerTitle: titleEl,
1739
+ headerSubtitle: subtitleEl,
1740
+ headerSubtitleText: subtitleTextEl,
1741
+ body,
1742
+ form,
1743
+ input,
1744
+ send,
1745
+ captureView,
1746
+ captureForm,
1747
+ captureError,
1748
+ phoneCountry,
1749
+ captureInputs,
1750
+ contact
1751
+ };
939
1752
  }
940
1753
  function buildHeader(cfg) {
941
1754
  const header = document.createElement("div");
942
1755
  header.className = "nexor-chat__header";
943
- const subtitleHtml = cfg.subtitle ? `<p class="nexor-chat__subtitle">
944
- <span class="nexor-chat__status-dot" aria-hidden="true"></span>${escapeHtml(cfg.subtitle)}
945
- </p>` : "";
946
- header.innerHTML = `
947
- <div>
948
- <p class="nexor-chat__title">${escapeHtml(cfg.title)}</p>
949
- ${subtitleHtml}
950
- </div>
951
- `;
1756
+ const info = document.createElement("div");
1757
+ const titleEl = document.createElement("p");
1758
+ titleEl.className = "nexor-chat__title";
1759
+ titleEl.textContent = cfg.title;
1760
+ info.appendChild(titleEl);
1761
+ const subtitleEl = document.createElement("p");
1762
+ subtitleEl.className = "nexor-chat__subtitle";
1763
+ const dot = document.createElement("span");
1764
+ dot.className = "nexor-chat__status-dot";
1765
+ dot.setAttribute("aria-hidden", "true");
1766
+ const subtitleTextEl = document.createElement("span");
1767
+ subtitleTextEl.className = "nexor-chat__subtitle-text";
1768
+ subtitleTextEl.textContent = cfg.subtitle ?? "";
1769
+ subtitleEl.appendChild(dot);
1770
+ subtitleEl.appendChild(subtitleTextEl);
1771
+ if (!cfg.subtitle) subtitleEl.hidden = true;
1772
+ info.appendChild(subtitleEl);
1773
+ header.appendChild(info);
1774
+ let restartBtn = null;
1775
+ if (cfg.showRestart) {
1776
+ restartBtn = document.createElement("button");
1777
+ restartBtn.type = "button";
1778
+ restartBtn.className = "nexor-chat__restart";
1779
+ restartBtn.setAttribute("aria-label", cfg.restartLabel);
1780
+ restartBtn.title = cfg.restartLabel;
1781
+ restartBtn.innerHTML = `
1782
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1783
+ <path d="M4 5v5h5M20 19v-5h-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1784
+ <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"/>
1785
+ </svg>
1786
+ `;
1787
+ header.appendChild(restartBtn);
1788
+ }
952
1789
  const closeBtn = document.createElement("button");
953
1790
  closeBtn.type = "button";
954
1791
  closeBtn.className = "nexor-chat__close";
@@ -959,64 +1796,182 @@ function buildHeader(cfg) {
959
1796
  </svg>
960
1797
  `;
961
1798
  header.appendChild(closeBtn);
962
- return { header, closeBtn };
1799
+ return { header, closeBtn, restartBtn, titleEl, subtitleEl, subtitleTextEl };
963
1800
  }
964
- function buildBody(cfg) {
1801
+ function buildBody() {
965
1802
  const body = document.createElement("div");
966
1803
  body.className = "nexor-chat__body";
967
- const captureInputs = {};
968
- let captureForm = null;
969
- if (needsCapture(cfg)) {
970
- captureForm = buildCaptureForm(cfg, captureInputs);
971
- body.appendChild(captureForm);
972
- }
973
- return { body, captureForm, captureInputs };
974
- }
1804
+ return body;
1805
+ }
1806
+ function buildCaptureView(cfg, inputsOut) {
1807
+ const view = document.createElement("div");
1808
+ view.className = "nexor-chat__capture-view";
1809
+ const { form, error, phoneCountry } = buildCaptureForm(cfg, inputsOut);
1810
+ view.appendChild(form);
1811
+ return { view, form, error, phoneCountry };
1812
+ }
1813
+ var DEFAULT_FIELD_PLACEHOLDERS = {
1814
+ first_name: "First name",
1815
+ last_name: "Last name",
1816
+ email: "Email",
1817
+ phone: "Phone"
1818
+ };
975
1819
  function buildCaptureForm(cfg, inputsOut) {
976
1820
  const form = document.createElement("form");
977
1821
  form.className = "nexor-chat__capture";
978
1822
  form.noValidate = true;
1823
+ let phoneCountry = null;
979
1824
  const label = document.createElement("p");
980
1825
  label.className = "nexor-chat__capture-label";
981
1826
  label.textContent = cfg.capture.label;
982
1827
  form.appendChild(label);
983
1828
  const fields = cfg.capture.fields;
1829
+ const ph = (f) => cfg.capture.placeholders[f] ?? DEFAULT_FIELD_PLACEHOLDERS[f];
984
1830
  if (fields.includes("first_name") || fields.includes("last_name")) {
985
1831
  const row = document.createElement("div");
986
1832
  row.className = "nexor-chat__capture-row";
987
1833
  if (fields.includes("first_name")) {
988
- inputsOut.first_name = makeInput("first_name", "First name", "text", cfg.lead?.first_name);
1834
+ inputsOut.first_name = makeInput("first_name", ph("first_name"), "text", cfg.lead?.first_name);
989
1835
  row.appendChild(inputsOut.first_name);
990
1836
  }
991
1837
  if (fields.includes("last_name")) {
992
- inputsOut.last_name = makeInput("last_name", "Last name", "text", cfg.lead?.last_name);
1838
+ inputsOut.last_name = makeInput("last_name", ph("last_name"), "text", cfg.lead?.last_name);
993
1839
  row.appendChild(inputsOut.last_name);
994
1840
  }
995
1841
  if (row.childElementCount > 0) form.appendChild(row);
996
1842
  }
997
1843
  if (fields.includes("email")) {
998
- inputsOut.email = makeInput("email", "Email", "email", cfg.lead?.email);
1844
+ inputsOut.email = makeInput("email", ph("email"), "email", cfg.lead?.email);
999
1845
  form.appendChild(inputsOut.email);
1000
1846
  }
1001
1847
  if (fields.includes("phone")) {
1002
- inputsOut.phone = makeInput("phone", "Phone", "tel", cfg.lead?.phone);
1003
- form.appendChild(inputsOut.phone);
1004
- }
1848
+ const built = buildPhoneField(cfg, ph("phone"));
1849
+ inputsOut.phone = built.input;
1850
+ phoneCountry = built.select;
1851
+ form.appendChild(built.wrap);
1852
+ }
1853
+ const error = document.createElement("p");
1854
+ error.className = "nexor-chat__capture-error";
1855
+ error.setAttribute("role", "alert");
1856
+ error.hidden = true;
1857
+ form.appendChild(error);
1005
1858
  const submit = document.createElement("button");
1006
1859
  submit.type = "submit";
1007
1860
  submit.className = "nexor-chat__capture-submit";
1008
1861
  submit.textContent = cfg.capture.submitLabel;
1009
1862
  form.appendChild(submit);
1010
- return form;
1863
+ return { form, error, phoneCountry };
1864
+ }
1865
+ function resolveCountryList(isos) {
1866
+ if (!isos.length) return COUNTRIES;
1867
+ const picked = isos.map((i) => countryByIso(i)).filter((c) => Boolean(c));
1868
+ return picked.length ? picked : COUNTRIES;
1869
+ }
1870
+ function buildPhoneField(cfg, placeholder) {
1871
+ const wrap = document.createElement("div");
1872
+ wrap.className = "nexor-chat__phone";
1873
+ const list = resolveCountryList(cfg.capture.phoneCountries);
1874
+ const def = resolveDefaultCountry(cfg.capture.phoneCountryCode);
1875
+ const select = document.createElement("select");
1876
+ select.className = "nexor-chat__phone-country";
1877
+ select.name = "phone_country";
1878
+ select.setAttribute("aria-label", "Country code");
1879
+ for (const c of list) {
1880
+ const opt = document.createElement("option");
1881
+ opt.value = c.iso;
1882
+ opt.textContent = `${c.flag} ${c.dial}`;
1883
+ if (c.iso === def.iso) opt.selected = true;
1884
+ select.appendChild(opt);
1885
+ }
1886
+ const input = document.createElement("input");
1887
+ input.name = "phone";
1888
+ input.type = "tel";
1889
+ input.inputMode = "numeric";
1890
+ input.autocomplete = "tel-national";
1891
+ input.placeholder = placeholder;
1892
+ input.className = "nexor-chat__input nexor-chat__phone-number";
1893
+ input.maxLength = maxNsn(def);
1894
+ if (cfg.lead?.phone) {
1895
+ const digits = String(cfg.lead.phone).replace(/\D/g, "");
1896
+ const dialDigits = def.dial.replace(/\D/g, "");
1897
+ const national = digits.startsWith(dialDigits) ? digits.slice(dialDigits.length) : digits;
1898
+ input.value = national.slice(0, input.maxLength);
1899
+ }
1900
+ wrap.appendChild(select);
1901
+ wrap.appendChild(input);
1902
+ return { wrap, select, input };
1903
+ }
1904
+ function buildContactView(cfg) {
1905
+ const rc = cfg.requestContact;
1906
+ const view = document.createElement("div");
1907
+ view.className = "nexor-chat__contact-view";
1908
+ view.setAttribute("data-state", "form");
1909
+ const form = document.createElement("form");
1910
+ form.className = "nexor-chat__capture nexor-chat__contact-form";
1911
+ form.noValidate = true;
1912
+ const inputs = {};
1913
+ const ph = (f) => cfg.capture.placeholders[f] ?? DEFAULT_FIELD_PLACEHOLDERS[f];
1914
+ const row = document.createElement("div");
1915
+ row.className = "nexor-chat__capture-row";
1916
+ inputs.first_name = makeInput("first_name", ph("first_name"), "text", cfg.lead?.first_name);
1917
+ inputs.last_name = makeInput("last_name", ph("last_name"), "text", cfg.lead?.last_name);
1918
+ row.appendChild(inputs.first_name);
1919
+ row.appendChild(inputs.last_name);
1920
+ form.appendChild(row);
1921
+ inputs.email = makeInput("email", ph("email"), "email", cfg.lead?.email);
1922
+ form.appendChild(inputs.email);
1923
+ const phone = buildPhoneField(cfg, ph("phone"));
1924
+ inputs.phone = phone.input;
1925
+ const phoneCountry = phone.select;
1926
+ form.appendChild(phone.wrap);
1927
+ const error = document.createElement("p");
1928
+ error.className = "nexor-chat__capture-error";
1929
+ error.setAttribute("role", "alert");
1930
+ error.hidden = true;
1931
+ form.appendChild(error);
1932
+ const submitBtn = document.createElement("button");
1933
+ submitBtn.type = "submit";
1934
+ submitBtn.className = "nexor-chat__capture-submit";
1935
+ form.appendChild(submitBtn);
1936
+ view.appendChild(form);
1937
+ const success = document.createElement("div");
1938
+ success.className = "nexor-chat__contact-success";
1939
+ const successIcon = document.createElement("div");
1940
+ successIcon.className = "nexor-chat__contact-success-icon";
1941
+ successIcon.innerHTML = `
1942
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1943
+ <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
1944
+ </svg>`;
1945
+ const successText = document.createElement("p");
1946
+ successText.className = "nexor-chat__contact-success-text";
1947
+ const successBtn = document.createElement("button");
1948
+ successBtn.type = "button";
1949
+ successBtn.className = "nexor-chat__capture-submit";
1950
+ successBtn.textContent = rc?.doneLabel ?? "Done";
1951
+ success.appendChild(successIcon);
1952
+ success.appendChild(successText);
1953
+ success.appendChild(successBtn);
1954
+ view.appendChild(success);
1955
+ return {
1956
+ view,
1957
+ form,
1958
+ inputs,
1959
+ error,
1960
+ phoneCountry,
1961
+ submitBtn,
1962
+ success,
1963
+ successText,
1964
+ successBtn
1965
+ };
1011
1966
  }
1012
- function buildComposer() {
1967
+ function buildComposer(cfg) {
1013
1968
  const form = document.createElement("form");
1014
1969
  form.className = "nexor-chat__composer";
1015
- const input = document.createElement("input");
1016
- input.type = "text";
1970
+ const input = document.createElement("textarea");
1017
1971
  input.className = "nexor-chat__input";
1018
- input.placeholder = "Type a message\u2026";
1019
- input.autocomplete = "off";
1972
+ input.placeholder = cfg.inputPlaceholder;
1973
+ input.rows = 1;
1974
+ input.setAttribute("autocomplete", "off");
1020
1975
  const send = document.createElement("button");
1021
1976
  send.type = "submit";
1022
1977
  send.className = "nexor-chat__send";
@@ -1031,10 +1986,23 @@ function buildComposer() {
1031
1986
  form.appendChild(send);
1032
1987
  return { form, input, send };
1033
1988
  }
1989
+ var NEXOR_WORDMARK = `
1990
+ <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">
1991
+ <defs>
1992
+ <linearGradient id="nexorChatBrandGrad" x1="1195.44" y1="21.3423" x2="1195.44" y2="504.454" gradientUnits="userSpaceOnUse">
1993
+ <stop stop-color="#232522"/>
1994
+ <stop offset="1" stop-color="#A1A1A1"/>
1995
+ </linearGradient>
1996
+ </defs>
1997
+ <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"/>
1998
+ <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)"/>
1999
+ <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"/>
2000
+ <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"/>
2001
+ </svg>`;
1034
2002
  function buildFooter() {
1035
2003
  const el = document.createElement("div");
1036
2004
  el.className = "nexor-chat__footer";
1037
- el.innerHTML = `Powered by <a href="https://www.getnexor.ai" target="_blank" rel="noopener noreferrer">Nexor</a>`;
2005
+ 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>`;
1038
2006
  return el;
1039
2007
  }
1040
2008
  function buildMessage(role, text, ts, locale) {
@@ -1079,6 +2047,20 @@ function formatRelativeTime(ts, locale, now = Date.now()) {
1079
2047
  }
1080
2048
  }
1081
2049
  }
2050
+ function buildResumeBanner(text, resetLabel) {
2051
+ const el = document.createElement("div");
2052
+ el.className = "nexor-chat__resume";
2053
+ const label = document.createElement("span");
2054
+ label.className = "nexor-chat__resume-text";
2055
+ label.textContent = text;
2056
+ const resetBtn = document.createElement("button");
2057
+ resetBtn.type = "button";
2058
+ resetBtn.className = "nexor-chat__resume-reset";
2059
+ resetBtn.textContent = resetLabel;
2060
+ el.appendChild(label);
2061
+ el.appendChild(resetBtn);
2062
+ return { el, resetBtn };
2063
+ }
1082
2064
  function buildTypingIndicator() {
1083
2065
  const el = document.createElement("div");
1084
2066
  el.className = "nexor-chat__typing";
@@ -1093,12 +2075,8 @@ function makeInput(name, placeholder, type, value) {
1093
2075
  if (value) i.value = value;
1094
2076
  return i;
1095
2077
  }
1096
- function escapeHtml(s) {
1097
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1098
- }
1099
2078
 
1100
2079
  // src/chat/index.ts
1101
- var TYPING_DELAY_MS = 220;
1102
2080
  function initChat(client, options) {
1103
2081
  ensureBrowser();
1104
2082
  if (!options || !options.workflowId) {
@@ -1106,95 +2084,603 @@ function initChat(client, options) {
1106
2084
  }
1107
2085
  const cfg = applyDefaults(options);
1108
2086
  injectStyles(cfg.accentColor, cfg.accentTextColor);
1109
- const sessionId = ensureSessionId();
1110
- const historyKey = `nexor.chat.history.${sessionId}`;
2087
+ let sessionId = ensureSessionId();
2088
+ let historyKey = `nexor.chat.history.${sessionId}`;
1111
2089
  const root = buildRoot(cfg);
1112
- const launcher = buildLauncher();
2090
+ const launcher = buildLauncher(cfg);
1113
2091
  const panel = buildPanel(cfg);
1114
- root.appendChild(launcher.el);
2092
+ root.appendChild(launcher.wrap);
1115
2093
  root.appendChild(panel.el);
1116
2094
  (cfg.container ?? document.body).appendChild(root);
2095
+ const hasFan = launcher.channelButtons.length > 0;
1117
2096
  const state = {
1118
2097
  isOpen: false,
2098
+ expanded: false,
2099
+ // speed-dial fan visible (touch/click-pinned)
1119
2100
  unread: 0,
1120
2101
  leadId: void 0,
1121
2102
  captured: !needsCapture(cfg),
1122
2103
  pendingLead: { ...cfg.lead ?? {} },
1123
- history: []
2104
+ history: [],
2105
+ // Dashboard-authored web-chat config (fetched once on init). Until it
2106
+ // resolves, assume enabled and fall back to the host-provided `greeting`.
2107
+ webchatEnabled: true,
2108
+ serverInitialMessage: null,
2109
+ configResolved: false,
2110
+ disabledShown: false,
2111
+ // The opening message actually displayed to the visitor — sent to the API
2112
+ // so the agent's <webchat_opening> matches what was shown (esp. for a
2113
+ // per-page openingMessage override).
2114
+ shownOpening: null,
2115
+ // Set by "start over" → the next turn tells the API to reset the conversation
2116
+ // episode (keep the lead, clear its prior run/thread/variables). Cleared once
2117
+ // that turn lands.
2118
+ resetEpisode: false,
2119
+ // True when this page load started with NO restored transcript. Used to
2120
+ // decide whether to offer the "continue / start fresh" choice when the API
2121
+ // reports it recognised a returning lead (matched_existing).
2122
+ freshLocalSession: false,
2123
+ // Ensures the returning-lead choice banner is offered at most once.
2124
+ episodeChoiceOffered: false,
2125
+ // The channel a visitor picked in the contact-request form (call/email/...).
2126
+ contactChannel: null,
2127
+ // In-flight "create lead on form submit" request — the first chat turn waits
2128
+ // on it so the backend matches that lead (no duplicate) rather than racing it.
2129
+ leadCreating: null
2130
+ };
2131
+ let configReady;
2132
+ const send = {
2133
+ pending: [],
2134
+ timer: 0,
2135
+ inFlight: false,
2136
+ typing: null,
2137
+ splitTimer: 0
2138
+ // staggers multi-bubble bot replies
1124
2139
  };
1125
- launcher.el.addEventListener("click", toggle);
2140
+ const greeting = { pending: false, timer: 0 };
2141
+ const GREETING_DELAY_MS = 2e3;
2142
+ launcher.el.addEventListener("click", hasFan ? toggleFan : toggle);
1126
2143
  panel.closeBtn.addEventListener("click", close);
2144
+ panel.restartBtn?.addEventListener("click", startOver);
1127
2145
  panel.form.addEventListener("submit", onComposerSubmit);
2146
+ panel.input.addEventListener("input", autosizeComposer);
2147
+ panel.input.addEventListener("keydown", (e) => {
2148
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
2149
+ e.preventDefault();
2150
+ onComposerSubmit(e);
2151
+ }
2152
+ });
1128
2153
  panel.captureForm?.addEventListener("submit", onCaptureSubmit);
1129
- syncComposerLock();
1130
- const restored = loadHistory();
1131
- if (restored.length) {
1132
- state.history = restored;
1133
- for (const m of restored) appendBubble(m.role === "user" ? "user" : "bot", m.text, m.ts);
2154
+ for (const input of Object.values(panel.captureInputs)) {
2155
+ input?.addEventListener("input", clearCaptureError);
2156
+ }
2157
+ if (panel.phoneCountry && panel.captureInputs.phone) {
2158
+ wirePhoneCap(panel.phoneCountry, panel.captureInputs.phone, clearCaptureError);
2159
+ }
2160
+ if (panel.contact) {
2161
+ const c = panel.contact;
2162
+ c.form.addEventListener("submit", onContactSubmit);
2163
+ for (const input of Object.values(c.inputs)) {
2164
+ input?.addEventListener("input", clearContactError);
2165
+ }
2166
+ if (c.phoneCountry && c.inputs.phone) {
2167
+ wirePhoneCap(c.phoneCountry, c.inputs.phone, clearContactError);
2168
+ }
2169
+ c.successBtn.addEventListener("click", close);
2170
+ }
2171
+ let onDocPointerDown = null;
2172
+ if (hasFan) {
2173
+ for (const btn of launcher.channelButtons) {
2174
+ btn.el.addEventListener("click", () => {
2175
+ if (btn.key === "chat") {
2176
+ collapseFan();
2177
+ open();
2178
+ } else if (btn.action === "contact") {
2179
+ collapseFan();
2180
+ openContact(btn.key);
2181
+ } else {
2182
+ collapseFan();
2183
+ }
2184
+ });
2185
+ }
2186
+ onDocPointerDown = (e) => {
2187
+ if (state.expanded && !launcher.wrap.contains(e.target)) collapseFan();
2188
+ };
2189
+ document.addEventListener("pointerdown", onDocPointerDown);
2190
+ }
2191
+ let resumeBanner = null;
2192
+ const persisted = loadPersisted();
2193
+ state.freshLocalSession = persisted.history.length === 0;
2194
+ if (persisted.history.length) {
2195
+ state.history = persisted.history;
2196
+ if (persisted.lead) state.pendingLead = { ...state.pendingLead, ...persisted.lead };
2197
+ state.captured = true;
2198
+ setView("chat");
2199
+ renderResumeBanner();
2200
+ for (const m of persisted.history) {
2201
+ appendBubble(m.role === "user" ? "user" : "bot", m.text, m.ts);
2202
+ }
1134
2203
  scrollToBottom();
1135
2204
  }
2205
+ syncComposerLock();
2206
+ configReady = loadServerConfig();
1136
2207
  if (cfg.openOnLoad) open();
2208
+ function toggleFan() {
2209
+ state.expanded ? collapseFan() : expandFan();
2210
+ }
2211
+ function expandFan() {
2212
+ state.expanded = true;
2213
+ launcher.wrap.setAttribute("data-expanded", "true");
2214
+ launcher.el.setAttribute("aria-expanded", "true");
2215
+ }
2216
+ function collapseFan() {
2217
+ if (!state.expanded) return;
2218
+ state.expanded = false;
2219
+ launcher.wrap.setAttribute("data-expanded", "false");
2220
+ launcher.el.setAttribute("aria-expanded", "false");
2221
+ }
1137
2222
  function open() {
2223
+ collapseFan();
1138
2224
  if (state.isOpen) return;
1139
2225
  state.isOpen = true;
1140
2226
  root.setAttribute("data-open", "true");
1141
2227
  clearUnread();
1142
- if (state.history.length === 0 && cfg.greeting && state.captured) {
1143
- appendBot(cfg.greeting);
2228
+ if (!state.webchatEnabled) {
2229
+ showDisabled();
2230
+ cfg.onOpen?.();
2231
+ return;
2232
+ }
2233
+ if (state.history.length === 0 && state.captured && canGreet()) {
2234
+ scheduleGreeting();
2235
+ }
2236
+ if (!state.captured) {
2237
+ const first = cfg.capture.fields[0];
2238
+ (first && panel.captureInputs[first] || panel.input).focus();
2239
+ } else {
2240
+ panel.input.focus();
1144
2241
  }
1145
- panel.input.focus();
1146
2242
  cfg.onOpen?.();
1147
2243
  }
1148
2244
  function close() {
1149
2245
  if (!state.isOpen) return;
1150
2246
  state.isOpen = false;
1151
2247
  root.setAttribute("data-open", "false");
2248
+ if (panel.el.getAttribute("data-view") === "contact") {
2249
+ state.contactChannel = null;
2250
+ setView(baseView());
2251
+ restoreHeader();
2252
+ }
1152
2253
  cfg.onClose?.();
1153
2254
  }
2255
+ function restoreHeader() {
2256
+ panel.headerTitle.textContent = cfg.title;
2257
+ panel.headerSubtitleText.textContent = cfg.subtitle ?? "";
2258
+ panel.headerSubtitle.hidden = !cfg.subtitle;
2259
+ }
1154
2260
  function toggle() {
1155
2261
  state.isOpen ? close() : open();
1156
2262
  }
1157
2263
  function onCaptureSubmit(e) {
1158
2264
  e.preventDefault();
2265
+ clearCaptureError();
1159
2266
  const collected = readCaptureFields(panel);
1160
2267
  const required = cfg.capture.fields;
2268
+ const msgs = cfg.capture.errorMessages;
1161
2269
  for (const f of required) {
1162
- if (!collected[f]) {
1163
- panel.captureInputs[f]?.focus();
1164
- return;
2270
+ if (!collected[f]) return failCapture(f, msgs.required);
2271
+ }
2272
+ if (required.includes("email") && !isValidEmail(collected.email ?? "")) {
2273
+ return failCapture("email", msgs.email);
2274
+ }
2275
+ if (required.includes("phone")) {
2276
+ const country = countryByIso(panel.phoneCountry?.value);
2277
+ const digits = (collected.phone ?? "").replace(/\D/g, "");
2278
+ if (!country || digits.length < country.nsn[0] || digits.length > country.nsn[1]) {
2279
+ return failCapture("phone", msgs.phone);
1165
2280
  }
2281
+ collected.phone = `${country.dial}${digits}`;
1166
2282
  }
1167
2283
  state.pendingLead = { ...state.pendingLead, ...collected };
1168
2284
  state.captured = true;
1169
- panel.captureForm?.remove();
2285
+ persistCapturedLead();
2286
+ setView("chat");
1170
2287
  syncComposerLock();
1171
2288
  panel.input.focus();
1172
- if (cfg.greeting && state.history.length === 0) appendBot(cfg.greeting);
2289
+ if (state.history.length === 0 && canGreet()) scheduleGreeting();
2290
+ }
2291
+ function persistCapturedLead() {
2292
+ if (!cfg.capture.createLeadOnSubmit || state.leadId) return;
2293
+ const lead = state.pendingLead;
2294
+ if (!lead.first_name && !lead.email && !lead.phone) return;
2295
+ const resolvedMeta = typeof cfg.metadata === "function" ? cfg.metadata() : cfg.metadata;
2296
+ state.leadCreating = client.createLead({
2297
+ first_name: lead.first_name || "Web visitor",
2298
+ last_name: lead.last_name,
2299
+ email: lead.email,
2300
+ phone: lead.phone,
2301
+ source: "web-chat",
2302
+ metadata: {
2303
+ ...resolvedMeta,
2304
+ web_chat_session_id: sessionId
2305
+ }
2306
+ }).then((res) => {
2307
+ if (res?.lead?.id && !state.leadId) {
2308
+ state.leadId = res.lead.id;
2309
+ cfg.onLeadCaptured?.(res.lead.id);
2310
+ }
2311
+ if (res && !res.existed) state.episodeChoiceOffered = true;
2312
+ }).catch((err) => {
2313
+ cfg.onError?.(err);
2314
+ }).finally(() => {
2315
+ state.leadCreating = null;
2316
+ });
2317
+ }
2318
+ function failCapture(field, message) {
2319
+ showCaptureError(message);
2320
+ const input = panel.captureInputs[field];
2321
+ const target = input?.closest(".nexor-chat__phone") || input;
2322
+ target?.classList.add("nexor-chat__input--error");
2323
+ input?.focus();
2324
+ }
2325
+ function showCaptureError(message) {
2326
+ if (!panel.captureError) return;
2327
+ panel.captureError.textContent = message;
2328
+ panel.captureError.hidden = false;
2329
+ }
2330
+ function clearCaptureError() {
2331
+ if (panel.captureError) {
2332
+ panel.captureError.textContent = "";
2333
+ panel.captureError.hidden = true;
2334
+ }
2335
+ panel.captureForm?.querySelectorAll(".nexor-chat__input--error").forEach((el) => el.classList.remove("nexor-chat__input--error"));
2336
+ }
2337
+ function wirePhoneCap(select, input, onChange) {
2338
+ const cap = () => {
2339
+ const c = countryByIso(select.value);
2340
+ const max = c ? maxNsn(c) : 15;
2341
+ input.maxLength = max;
2342
+ const digits = input.value.replace(/\D/g, "").slice(0, max);
2343
+ if (input.value !== digits) input.value = digits;
2344
+ };
2345
+ input.addEventListener("input", cap);
2346
+ select.addEventListener("change", () => {
2347
+ cap();
2348
+ onChange?.();
2349
+ });
2350
+ }
2351
+ function openContact(channel) {
2352
+ const c = panel.contact;
2353
+ const rc = cfg.requestContact;
2354
+ if (!c || !rc) return;
2355
+ state.contactChannel = channel;
2356
+ panel.headerTitle.textContent = rc.title[channel] ?? cfg.title;
2357
+ panel.headerSubtitleText.textContent = rc.subtitle[channel] ?? "";
2358
+ panel.headerSubtitle.hidden = !rc.subtitle[channel];
2359
+ c.submitBtn.textContent = rc.submitLabel[channel] ?? "";
2360
+ c.successText.textContent = rc.successText[channel] ?? "";
2361
+ c.view.setAttribute("data-state", "form");
2362
+ c.submitBtn.disabled = false;
2363
+ clearContactError();
2364
+ resetContactForm();
2365
+ cancelGreeting();
2366
+ state.isOpen = true;
2367
+ root.setAttribute("data-open", "true");
2368
+ clearUnread();
2369
+ setView("contact");
2370
+ const firstEmpty = [c.inputs.first_name, c.inputs.email, c.inputs.phone].find(
2371
+ (i) => i && !i.value
2372
+ );
2373
+ (firstEmpty ?? c.inputs.first_name ?? null)?.focus();
2374
+ cfg.onOpen?.();
2375
+ }
2376
+ function resetContactForm() {
2377
+ const c = panel.contact;
2378
+ if (!c) return;
2379
+ const lead = state.pendingLead;
2380
+ if (c.inputs.first_name) c.inputs.first_name.value = lead.first_name ?? "";
2381
+ if (c.inputs.last_name) c.inputs.last_name.value = lead.last_name ?? "";
2382
+ if (c.inputs.email) c.inputs.email.value = lead.email ?? "";
2383
+ const def = resolveDefaultCountry(cfg.capture.phoneCountryCode);
2384
+ if (c.phoneCountry) c.phoneCountry.value = def.iso;
2385
+ if (c.inputs.phone) {
2386
+ let national = "";
2387
+ if (lead.phone) {
2388
+ const digits = String(lead.phone).replace(/\D/g, "");
2389
+ const dialDigits = def.dial.replace(/\D/g, "");
2390
+ national = (digits.startsWith(dialDigits) ? digits.slice(dialDigits.length) : digits).slice(
2391
+ 0,
2392
+ maxNsn(def)
2393
+ );
2394
+ }
2395
+ c.inputs.phone.maxLength = maxNsn(def);
2396
+ c.inputs.phone.value = national;
2397
+ }
2398
+ }
2399
+ function onContactSubmit(e) {
2400
+ e.preventDefault();
2401
+ const c = panel.contact;
2402
+ const channel = state.contactChannel;
2403
+ if (!c || !channel) return;
2404
+ clearContactError();
2405
+ const msgs = cfg.capture.errorMessages;
2406
+ const first = c.inputs.first_name?.value.trim() ?? "";
2407
+ const last = c.inputs.last_name?.value.trim() ?? "";
2408
+ const email = c.inputs.email?.value.trim() ?? "";
2409
+ const phoneDigits = (c.inputs.phone?.value ?? "").replace(/\D/g, "");
2410
+ if (!first) return failContact("first_name", msgs.required);
2411
+ const needEmail = channel === "email";
2412
+ if (needEmail && !email) return failContact("email", msgs.required);
2413
+ if (email && !isValidEmail(email)) return failContact("email", msgs.email);
2414
+ const needPhone = channel === "call" || channel === "whatsapp";
2415
+ let e164;
2416
+ if (needPhone || phoneDigits) {
2417
+ const country = countryByIso(c.phoneCountry?.value);
2418
+ if (!country || phoneDigits.length < country.nsn[0] || phoneDigits.length > country.nsn[1]) {
2419
+ return failContact("phone", msgs.phone);
2420
+ }
2421
+ e164 = `${country.dial}${phoneDigits}`;
2422
+ }
2423
+ void submitContact(channel, { first, last, email, phone: e164 });
2424
+ }
2425
+ async function submitContact(channel, data) {
2426
+ const c = panel.contact;
2427
+ const rc = cfg.requestContact;
2428
+ if (!c || !rc) return;
2429
+ c.submitBtn.disabled = true;
2430
+ const resolvedMeta = typeof cfg.metadata === "function" ? cfg.metadata() : cfg.metadata;
2431
+ const meta = resolvedMeta ?? void 0;
2432
+ const pageUrl = meta && meta.page_url || (typeof window !== "undefined" ? window.location.href : void 0);
2433
+ try {
2434
+ const res = await client.requestContact(
2435
+ {
2436
+ workflow_id: cfg.workflowId,
2437
+ first_name: data.first,
2438
+ last_name: data.last || void 0,
2439
+ email: data.email || void 0,
2440
+ phone: data.phone,
2441
+ force_first_channel: channel,
2442
+ source: "web-chat",
2443
+ // Context for the agent's first outreach: they were on the site and
2444
+ // explicitly asked to be reached on this channel.
2445
+ metadata: {
2446
+ ...meta ?? {},
2447
+ contact_request: true,
2448
+ requested_channel: channel,
2449
+ requested_at: (/* @__PURE__ */ new Date()).toISOString(),
2450
+ context_note: `Website visitor asked to be contacted via ${channel}${pageUrl ? ` from ${pageUrl}` : ""}.`
2451
+ }
2452
+ },
2453
+ { timeoutMs: cfg.requestTimeoutMs, maxRetries: 0 }
2454
+ );
2455
+ if (res?.lead?.id && !state.leadId) {
2456
+ state.leadId = res.lead.id;
2457
+ cfg.onLeadCaptured?.(res.lead.id);
2458
+ }
2459
+ c.successText.textContent = rc.successText[channel] ?? "";
2460
+ panel.headerSubtitle.hidden = true;
2461
+ c.view.setAttribute("data-state", "sent");
2462
+ } catch (err) {
2463
+ c.submitBtn.disabled = false;
2464
+ showContactError(rc.errorText);
2465
+ cfg.onError?.(err);
2466
+ }
2467
+ }
2468
+ function failContact(field, message) {
2469
+ const c = panel.contact;
2470
+ if (!c) return;
2471
+ showContactError(message);
2472
+ const input = c.inputs[field];
2473
+ const target = input?.closest(".nexor-chat__phone") || input;
2474
+ target?.classList.add("nexor-chat__input--error");
2475
+ input?.focus();
2476
+ }
2477
+ function showContactError(message) {
2478
+ const c = panel.contact;
2479
+ if (!c) return;
2480
+ c.error.textContent = message;
2481
+ c.error.hidden = false;
2482
+ }
2483
+ function clearContactError() {
2484
+ const c = panel.contact;
2485
+ if (!c) return;
2486
+ c.error.textContent = "";
2487
+ c.error.hidden = true;
2488
+ c.form.querySelectorAll(".nexor-chat__input--error").forEach((el) => el.classList.remove("nexor-chat__input--error"));
2489
+ }
2490
+ function setView(view) {
2491
+ panel.el.setAttribute("data-view", view);
2492
+ }
2493
+ function baseView() {
2494
+ return state.captured ? "chat" : "capture";
2495
+ }
2496
+ function identityText() {
2497
+ const l = state.pendingLead;
2498
+ const who = (l.first_name || l.email || l.phone || "").trim();
2499
+ return who ? cfg.resumeWithNameText.replace("{name}", who) : cfg.resumeText;
2500
+ }
2501
+ function renderResumeBanner(prepend = false) {
2502
+ if (resumeBanner) return;
2503
+ const banner = buildResumeBanner(identityText(), cfg.startOverText);
2504
+ banner.resetBtn.addEventListener("click", startOver);
2505
+ if (prepend && panel.body.firstChild) {
2506
+ panel.body.insertBefore(banner.el, panel.body.firstChild);
2507
+ } else {
2508
+ panel.body.appendChild(banner.el);
2509
+ }
2510
+ resumeBanner = banner.el;
2511
+ }
2512
+ function startOver() {
2513
+ cancelGreeting();
2514
+ if (send.timer) {
2515
+ window.clearTimeout(send.timer);
2516
+ send.timer = 0;
2517
+ }
2518
+ if (send.splitTimer) {
2519
+ window.clearTimeout(send.splitTimer);
2520
+ send.splitTimer = 0;
2521
+ }
2522
+ send.pending = [];
2523
+ hideTyping();
2524
+ try {
2525
+ window.localStorage.removeItem(historyKey);
2526
+ } catch {
2527
+ }
2528
+ state.history = [];
2529
+ state.shownOpening = null;
2530
+ resumeBanner = null;
2531
+ panel.body.replaceChildren();
2532
+ if (panel.captureView) {
2533
+ state.pendingLead = { ...cfg.lead ?? {} };
2534
+ state.captured = false;
2535
+ state.leadId = void 0;
2536
+ state.episodeChoiceOffered = false;
2537
+ state.resetEpisode = true;
2538
+ resetCaptureForm();
2539
+ setView("capture");
2540
+ syncComposerLock();
2541
+ const first = cfg.capture.fields[0];
2542
+ (first && panel.captureInputs[first] || panel.input).focus();
2543
+ return;
2544
+ }
2545
+ state.resetEpisode = true;
2546
+ setView("chat");
2547
+ syncComposerLock();
2548
+ if (state.captured && canGreet()) scheduleGreeting();
2549
+ panel.input.focus();
2550
+ }
2551
+ function resetCaptureForm() {
2552
+ const lead = state.pendingLead;
2553
+ if (panel.captureInputs.first_name) panel.captureInputs.first_name.value = lead.first_name ?? "";
2554
+ if (panel.captureInputs.last_name) panel.captureInputs.last_name.value = lead.last_name ?? "";
2555
+ if (panel.captureInputs.email) panel.captureInputs.email.value = lead.email ?? "";
2556
+ const def = resolveDefaultCountry(cfg.capture.phoneCountryCode);
2557
+ if (panel.phoneCountry) panel.phoneCountry.value = def.iso;
2558
+ if (panel.captureInputs.phone) {
2559
+ let national = "";
2560
+ if (lead.phone) {
2561
+ const digits = String(lead.phone).replace(/\D/g, "");
2562
+ const dialDigits = def.dial.replace(/\D/g, "");
2563
+ national = (digits.startsWith(dialDigits) ? digits.slice(dialDigits.length) : digits).slice(
2564
+ 0,
2565
+ maxNsn(def)
2566
+ );
2567
+ }
2568
+ panel.captureInputs.phone.maxLength = maxNsn(def);
2569
+ panel.captureInputs.phone.value = national;
2570
+ }
2571
+ clearCaptureError();
1173
2572
  }
1174
- const send = {
1175
- pending: [],
1176
- timer: 0,
1177
- typingTimer: 0,
1178
- inFlight: false,
1179
- typing: null
1180
- };
1181
2573
  function onComposerSubmit(e) {
1182
2574
  e.preventDefault();
1183
2575
  const text = panel.input.value.trim();
1184
2576
  if (!text) return;
1185
2577
  panel.input.value = "";
2578
+ autosizeComposer();
1186
2579
  queueMessage(text);
1187
2580
  }
2581
+ function autosizeComposer() {
2582
+ const el = panel.input;
2583
+ el.style.height = "auto";
2584
+ el.style.height = `${el.scrollHeight + 2}px`;
2585
+ }
1188
2586
  function queueMessage(text) {
1189
2587
  if (!state.captured) {
1190
- panel.captureInputs.first_name?.focus();
2588
+ const first = cfg.capture.fields[0];
2589
+ (first && panel.captureInputs[first] || panel.input).focus();
1191
2590
  return;
1192
2591
  }
2592
+ cancelGreeting();
1193
2593
  appendUser(text);
1194
2594
  send.pending.push(text);
1195
2595
  scheduleFlush();
1196
2596
  }
2597
+ function greetingText() {
2598
+ const raw = cfg.openingMessage || state.serverInitialMessage;
2599
+ if (raw) return renderLeadTemplate(raw, state.pendingLead);
2600
+ return cfg.greeting ?? "";
2601
+ }
2602
+ function canGreet() {
2603
+ if (!state.webchatEnabled) return false;
2604
+ if (cfg.openingMessage) return true;
2605
+ if (!state.configResolved) return true;
2606
+ return !!state.serverInitialMessage || !!cfg.greeting;
2607
+ }
2608
+ function scheduleGreeting() {
2609
+ if (greeting.pending) return;
2610
+ greeting.pending = true;
2611
+ showTyping();
2612
+ greeting.timer = window.setTimeout(async () => {
2613
+ if (!cfg.openingMessage) await configReady;
2614
+ if (!greeting.pending) return;
2615
+ greeting.pending = false;
2616
+ greeting.timer = 0;
2617
+ hideTyping();
2618
+ const text = greetingText();
2619
+ if (text) {
2620
+ state.shownOpening = text;
2621
+ appendBot(text);
2622
+ }
2623
+ }, GREETING_DELAY_MS);
2624
+ }
2625
+ function cancelGreeting() {
2626
+ if (!greeting.pending) return;
2627
+ if (greeting.timer) window.clearTimeout(greeting.timer);
2628
+ greeting.timer = 0;
2629
+ greeting.pending = false;
2630
+ hideTyping();
2631
+ }
2632
+ async function loadServerConfig() {
2633
+ try {
2634
+ const res = await client.getChatConfig(cfg.workflowId, {
2635
+ timeoutMs: 1e4,
2636
+ maxRetries: 1
2637
+ });
2638
+ applyServerConfig(res.enabled !== false, res.initial_message ?? null);
2639
+ } catch {
2640
+ applyServerConfig(true, null);
2641
+ }
2642
+ }
2643
+ function applyServerConfig(enabled, initialMessage) {
2644
+ state.configResolved = true;
2645
+ state.serverInitialMessage = initialMessage;
2646
+ state.webchatEnabled = enabled;
2647
+ if (!enabled) {
2648
+ hideChatChannelButton();
2649
+ cancelGreeting();
2650
+ if (state.isOpen) showDisabled();
2651
+ }
2652
+ }
2653
+ function showDisabled() {
2654
+ setView("chat");
2655
+ if (!state.disabledShown) {
2656
+ appendSystem(cfg.disabledText);
2657
+ state.disabledShown = true;
2658
+ }
2659
+ panel.input.disabled = true;
2660
+ panel.send.disabled = true;
2661
+ panel.input.placeholder = cfg.disabledText;
2662
+ }
2663
+ function hideChatChannelButton() {
2664
+ for (const btn of launcher.channelButtons) {
2665
+ if (btn.key !== "chat") continue;
2666
+ const item = btn.el.closest(".nexor-chat__channel-item");
2667
+ if (item instanceof HTMLElement) item.style.display = "none";
2668
+ }
2669
+ }
2670
+ function showTyping() {
2671
+ if (send.typing) {
2672
+ panel.body.appendChild(send.typing);
2673
+ scrollToBottom();
2674
+ return;
2675
+ }
2676
+ send.typing = appendNode(buildTypingIndicator());
2677
+ }
2678
+ function hideTyping() {
2679
+ send.typing?.remove();
2680
+ send.typing = null;
2681
+ }
1197
2682
  function scheduleFlush() {
2683
+ showTyping();
1198
2684
  if (send.timer) window.clearTimeout(send.timer);
1199
2685
  send.timer = window.setTimeout(() => void flush(), cfg.debounceMs);
1200
2686
  }
@@ -1203,37 +2689,65 @@ function initChat(client, options) {
1203
2689
  const batch = send.pending.splice(0, send.pending.length);
1204
2690
  const message = batch.join("\n");
1205
2691
  send.inFlight = true;
1206
- send.typingTimer = window.setTimeout(() => {
1207
- send.typing = appendNode(buildTypingIndicator());
1208
- }, TYPING_DELAY_MS);
2692
+ if (state.leadCreating) {
2693
+ try {
2694
+ await state.leadCreating;
2695
+ } catch {
2696
+ }
2697
+ }
2698
+ const turnResetsEpisode = state.resetEpisode;
1209
2699
  try {
1210
- const res = await client.chat({
1211
- workflow_id: cfg.workflowId,
1212
- session_id: sessionId,
1213
- message,
1214
- system_prompt: cfg.systemPrompt,
1215
- client_prompt: cfg.clientPrompt,
1216
- lead: Object.keys(state.pendingLead).length ? state.pendingLead : void 0,
1217
- metadata: cfg.metadata
1218
- });
2700
+ const res = await client.chat(
2701
+ {
2702
+ workflow_id: cfg.workflowId,
2703
+ session_id: sessionId,
2704
+ message,
2705
+ // Additive page/instance prompt injection (API appends to the
2706
+ // workflow persona). Per route, the host passes different values.
2707
+ system_prompt: cfg.systemPrompt,
2708
+ client_prompt: cfg.clientPrompt,
2709
+ // The opening the visitor actually saw, so the agent doesn't repeat
2710
+ // or contradict a per-page opening override.
2711
+ opening_shown: state.shownOpening ?? void 0,
2712
+ // First turn after "start over" → reset the conversation episode on
2713
+ // the same lead (clear prior run/thread/variables server-side).
2714
+ reset_episode: turnResetsEpisode || void 0,
2715
+ lead: Object.keys(state.pendingLead).length ? state.pendingLead : void 0,
2716
+ // Resolve metadata per-turn so a function form picks up live SPA state
2717
+ // (e.g. the current route) without re-mounting the widget.
2718
+ metadata: typeof cfg.metadata === "function" ? cfg.metadata() : cfg.metadata
2719
+ },
2720
+ // A chat turn is NOT idempotent (re-sending re-runs the LLM and
2721
+ // re-inserts the message). Never retry; just give it a generous timeout.
2722
+ { timeoutMs: cfg.requestTimeoutMs, maxRetries: 0 }
2723
+ );
2724
+ if (turnResetsEpisode) state.resetEpisode = false;
1219
2725
  if (res.lead_id && !state.leadId) {
1220
2726
  state.leadId = res.lead_id;
1221
2727
  cfg.onLeadCaptured?.(state.leadId);
1222
2728
  }
2729
+ if (res.matched_existing && state.freshLocalSession && !state.episodeChoiceOffered && !turnResetsEpisode) {
2730
+ state.episodeChoiceOffered = true;
2731
+ renderResumeBanner(true);
2732
+ }
1223
2733
  const reply = (res.reply ?? "").trim();
2734
+ hideTyping();
1224
2735
  if (reply) {
1225
- appendBot(reply);
2736
+ appendBotReply(reply);
1226
2737
  } else {
1227
2738
  appendSystem(cfg.errorText);
1228
2739
  cfg.onError?.(new Error("Nexor SDK: empty reply from server"));
1229
2740
  }
1230
2741
  } catch (err) {
1231
- appendSystem(cfg.errorText);
2742
+ hideTyping();
2743
+ if (/web chat is not enabled/i.test(err?.message || "")) {
2744
+ applyServerConfig(false, state.serverInitialMessage);
2745
+ } else {
2746
+ appendSystem(cfg.errorText);
2747
+ }
1232
2748
  cfg.onError?.(err);
1233
2749
  } finally {
1234
- window.clearTimeout(send.typingTimer);
1235
- send.typing?.remove();
1236
- send.typing = null;
2750
+ if (!send.splitTimer) hideTyping();
1237
2751
  send.inFlight = false;
1238
2752
  panel.input.focus();
1239
2753
  if (send.pending.length) scheduleFlush();
@@ -1246,37 +2760,84 @@ function initChat(client, options) {
1246
2760
  saveHistory();
1247
2761
  cfg.onMessage?.({ role: "user", text });
1248
2762
  }
1249
- function appendBot(text) {
1250
- const ts = Date.now();
2763
+ function appendBot(text, showTime = true) {
2764
+ const ts = showTime ? Date.now() : void 0;
1251
2765
  appendBubble("bot", text, ts);
1252
2766
  state.history.push({ role: "bot", text, ts });
1253
2767
  saveHistory();
1254
2768
  if (!state.isOpen) bumpUnread();
1255
2769
  cfg.onMessage?.({ role: "bot", text });
1256
2770
  }
2771
+ function appendBotReply(text) {
2772
+ const parts = cfg.splitReplies ? splitReply(text) : [text];
2773
+ if (parts.length <= 1) {
2774
+ appendBot(text);
2775
+ return;
2776
+ }
2777
+ const [first, ...rest] = parts;
2778
+ appendBot(first ?? text, false);
2779
+ let i = 0;
2780
+ const sendNext = () => {
2781
+ const part = rest[i];
2782
+ if (part === void 0) return;
2783
+ const isLast = i === rest.length - 1;
2784
+ i++;
2785
+ showTyping();
2786
+ const delay = cfg.splitDelayMs > 0 ? cfg.splitDelayMs : typingDelayFor(part);
2787
+ send.splitTimer = window.setTimeout(() => {
2788
+ send.splitTimer = 0;
2789
+ hideTyping();
2790
+ appendBot(part, isLast);
2791
+ sendNext();
2792
+ }, delay);
2793
+ };
2794
+ sendNext();
2795
+ }
2796
+ function splitReply(text) {
2797
+ const parts = text.split(/\n[ \t]*\n+/).map((s) => s.trim()).filter(Boolean);
2798
+ return parts.length ? parts : [text];
2799
+ }
2800
+ function typingDelayFor(text) {
2801
+ const MIN = 3e3;
2802
+ const MAX = 5e3;
2803
+ const FULL_AT = 180;
2804
+ const scaled = MIN + Math.min(text.length, FULL_AT) * ((MAX - MIN) / FULL_AT);
2805
+ const jitter = Math.random() * 300 - 150;
2806
+ return Math.round(Math.max(MIN, Math.min(MAX, scaled + jitter)));
2807
+ }
1257
2808
  function saveHistory() {
1258
2809
  try {
1259
2810
  window.localStorage.setItem(
1260
2811
  historyKey,
1261
- JSON.stringify({ ts: Date.now(), history: state.history })
2812
+ // Persist the known lead alongside the transcript so a returning visitor
2813
+ // is recognised (and not re-asked for their details) after a reload.
2814
+ JSON.stringify({
2815
+ ts: Date.now(),
2816
+ history: state.history,
2817
+ lead: state.pendingLead
2818
+ })
1262
2819
  );
1263
2820
  touchSession();
1264
2821
  } catch {
1265
2822
  }
1266
2823
  }
1267
- function loadHistory() {
2824
+ function loadPersisted() {
2825
+ const empty = { history: [], lead: void 0 };
1268
2826
  try {
1269
2827
  const raw = window.localStorage.getItem(historyKey);
1270
- if (!raw) return [];
2828
+ if (!raw) return empty;
1271
2829
  const o = JSON.parse(raw);
1272
- if (!o || !Array.isArray(o.history)) return [];
2830
+ if (!o || !Array.isArray(o.history)) return empty;
1273
2831
  if (Date.now() - (o.ts || 0) > SESSION_RETENTION_MS) {
1274
2832
  window.localStorage.removeItem(historyKey);
1275
- return [];
2833
+ return empty;
1276
2834
  }
1277
- return o.history;
2835
+ return {
2836
+ history: o.history,
2837
+ lead: o.lead && typeof o.lead === "object" ? o.lead : void 0
2838
+ };
1278
2839
  } catch {
1279
- return [];
2840
+ return empty;
1280
2841
  }
1281
2842
  }
1282
2843
  function appendSystem(text) {
@@ -1313,7 +2874,7 @@ function initChat(client, options) {
1313
2874
  const lock = !state.captured;
1314
2875
  panel.input.disabled = lock;
1315
2876
  panel.send.disabled = lock;
1316
- panel.input.placeholder = lock ? "Complete the form above to start\u2026" : "Type a message\u2026";
2877
+ panel.input.placeholder = lock ? "Complete the form above to start\u2026" : cfg.inputPlaceholder;
1317
2878
  }
1318
2879
  return {
1319
2880
  open,
@@ -1327,8 +2888,10 @@ function initChat(client, options) {
1327
2888
  },
1328
2889
  destroy: () => {
1329
2890
  if (send.timer) window.clearTimeout(send.timer);
1330
- if (send.typingTimer) window.clearTimeout(send.typingTimer);
2891
+ if (send.splitTimer) window.clearTimeout(send.splitTimer);
2892
+ if (greeting.timer) window.clearTimeout(greeting.timer);
1331
2893
  window.clearInterval(refreshTimer);
2894
+ if (onDocPointerDown) document.removeEventListener("pointerdown", onDocPointerDown);
1332
2895
  root.remove();
1333
2896
  },
1334
2897
  getSessionId: () => sessionId
@@ -1352,7 +2915,7 @@ function readCaptureFields(panel) {
1352
2915
  if (inputs.first_name) out.first_name = inputs.first_name.value.trim();
1353
2916
  if (inputs.last_name) out.last_name = inputs.last_name.value.trim();
1354
2917
  if (inputs.email) out.email = inputs.email.value.trim();
1355
- if (inputs.phone) out.phone = inputs.phone.value.trim();
2918
+ if (inputs.phone) out.phone = inputs.phone.value.replace(/\D/g, "");
1356
2919
  return out;
1357
2920
  }
1358
2921