@getnexorai/sdk 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/chat.cjs +1575 -88
- package/dist/chat.cjs.map +1 -1
- package/dist/chat.d.cts +2 -2
- package/dist/chat.d.ts +2 -2
- package/dist/chat.js +1 -1
- package/dist/chunk-WUUXZIQN.js +2483 -0
- package/dist/chunk-WUUXZIQN.js.map +1 -0
- package/dist/{config-khGNZDHI.d.cts → config-C39P5Aax.d.cts} +199 -4
- package/dist/{config-khGNZDHI.d.ts → config-C39P5Aax.d.ts} +199 -4
- package/dist/index.cjs +1616 -89
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +41 -1
- package/dist/index.js.map +1 -1
- package/dist/nexor.iife.js +490 -24
- package/dist/nexor.iife.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-NBE72ISL.js +0 -996
- package/dist/chunk-NBE72ISL.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -383,6 +383,23 @@ var NexorClient = class {
|
|
|
383
383
|
});
|
|
384
384
|
}
|
|
385
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
|
+
}
|
|
386
403
|
/**
|
|
387
404
|
* Send a turn in a browser-embedded chat session. Backed by
|
|
388
405
|
* POST /api/public/chat on the Nexor API.
|
|
@@ -397,6 +414,29 @@ var NexorClient = class {
|
|
|
397
414
|
options
|
|
398
415
|
});
|
|
399
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
|
+
}
|
|
400
440
|
};
|
|
401
441
|
|
|
402
442
|
// src/chat/config.ts
|
|
@@ -405,15 +445,25 @@ function applyDefaults(o) {
|
|
|
405
445
|
workflowId: o.workflowId,
|
|
406
446
|
title: o.title ?? "Chat with us",
|
|
407
447
|
subtitle: o.subtitle ?? "We typically reply in a few minutes",
|
|
448
|
+
openingMessage: o.openingMessage,
|
|
408
449
|
greeting: o.greeting ?? "Hi there! \u{1F44B} How can we help?",
|
|
409
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.",
|
|
410
452
|
locale: o.locale ?? (typeof navigator !== "undefined" ? navigator.language : void 0),
|
|
411
453
|
accentColor: o.accentColor ?? "#111827",
|
|
412
454
|
accentTextColor: o.accentTextColor ?? "#ffffff",
|
|
413
455
|
showBranding: o.showBranding ?? true,
|
|
456
|
+
inputPlaceholder: o.inputPlaceholder ?? "Type a message\u2026",
|
|
457
|
+
showRestart: o.showRestart ?? true,
|
|
458
|
+
restartLabel: o.restartLabel ?? "Start over",
|
|
414
459
|
position: o.position ?? "bottom-right",
|
|
415
460
|
openOnLoad: o.openOnLoad ?? false,
|
|
461
|
+
channels: resolveChannels(o.channels),
|
|
462
|
+
requestContact: resolveRequestContact(o.channels?.requestContact),
|
|
416
463
|
debounceMs: o.debounceMs ?? 700,
|
|
464
|
+
splitReplies: o.splitReplies ?? true,
|
|
465
|
+
splitDelayMs: o.splitDelayMs ?? 0,
|
|
466
|
+
// 0 = auto (random 1000–1500ms per bubble)
|
|
417
467
|
requestTimeoutMs: o.requestTimeoutMs ?? 6e4,
|
|
418
468
|
container: o.container,
|
|
419
469
|
systemPrompt: o.systemPrompt,
|
|
@@ -422,10 +472,22 @@ function applyDefaults(o) {
|
|
|
422
472
|
capture: {
|
|
423
473
|
fields: o.capture?.fields ?? ["first_name", "email"],
|
|
424
474
|
mode: o.capture?.mode ?? "before",
|
|
475
|
+
createLeadOnSubmit: o.capture?.createLeadOnSubmit ?? false,
|
|
425
476
|
label: o.capture?.label ?? "Tell us a bit about you to get started:",
|
|
426
|
-
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
|
+
}
|
|
427
486
|
},
|
|
428
487
|
metadata: o.metadata,
|
|
488
|
+
resumeText: o.resumeText ?? "Continuing your conversation",
|
|
489
|
+
resumeWithNameText: o.resumeWithNameText ?? "Continuing as {name}",
|
|
490
|
+
startOverText: o.startOverText ?? "Start over",
|
|
429
491
|
onOpen: o.onOpen,
|
|
430
492
|
onClose: o.onClose,
|
|
431
493
|
onMessage: o.onMessage,
|
|
@@ -433,6 +495,126 @@ function applyDefaults(o) {
|
|
|
433
495
|
onError: o.onError
|
|
434
496
|
};
|
|
435
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
|
+
}
|
|
436
618
|
function needsCapture(cfg) {
|
|
437
619
|
if (cfg.capture.mode === "skip") return false;
|
|
438
620
|
if (cfg.lead) {
|
|
@@ -493,6 +675,95 @@ function randomHex(bytes) {
|
|
|
493
675
|
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
494
676
|
}
|
|
495
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
|
+
|
|
496
767
|
// src/chat/styles.ts
|
|
497
768
|
var widgetCss = (accent, accentText) => `
|
|
498
769
|
.nexor-chat,
|
|
@@ -516,6 +787,12 @@ var widgetCss = (accent, accentText) => `
|
|
|
516
787
|
left: 20px;
|
|
517
788
|
}
|
|
518
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
|
+
|
|
519
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 */
|
|
520
797
|
.nexor-chat__launcher {
|
|
521
798
|
width: 56px;
|
|
@@ -552,6 +829,12 @@ var widgetCss = (accent, accentText) => `
|
|
|
552
829
|
transform: scale(0.85);
|
|
553
830
|
opacity: 0.75;
|
|
554
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
|
+
}
|
|
555
838
|
.nexor-chat__launcher svg { width: 24px; height: 24px; transition: transform 200ms ease; }
|
|
556
839
|
.nexor-chat[data-open="true"] .nexor-chat__launcher svg { transform: rotate(8deg); }
|
|
557
840
|
|
|
@@ -583,6 +866,118 @@ var widgetCss = (accent, accentText) => `
|
|
|
583
866
|
to { transform: scale(1); }
|
|
584
867
|
}
|
|
585
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
|
+
|
|
586
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 */
|
|
587
982
|
.nexor-chat__panel {
|
|
588
983
|
position: absolute;
|
|
@@ -636,6 +1031,7 @@ var widgetCss = (accent, accentText) => `
|
|
|
636
1031
|
font-size: 12px;
|
|
637
1032
|
opacity: 0.85;
|
|
638
1033
|
margin: 2px 0 0;
|
|
1034
|
+
line-height: 1.4;
|
|
639
1035
|
}
|
|
640
1036
|
.nexor-chat__status-dot {
|
|
641
1037
|
display: inline-block;
|
|
@@ -665,6 +1061,23 @@ var widgetCss = (accent, accentText) => `
|
|
|
665
1061
|
.nexor-chat__close:hover { background: rgba(255,255,255,0.18); opacity: 1; }
|
|
666
1062
|
.nexor-chat__close:active { transform: scale(0.92); }
|
|
667
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; }
|
|
668
1081
|
|
|
669
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 */
|
|
670
1083
|
.nexor-chat__body {
|
|
@@ -764,6 +1177,72 @@ var widgetCss = (accent, accentText) => `
|
|
|
764
1177
|
30% { opacity: 1; transform: translateY(-3px); }
|
|
765
1178
|
}
|
|
766
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
|
+
|
|
767
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 */
|
|
768
1247
|
.nexor-chat__capture {
|
|
769
1248
|
background: #fff;
|
|
@@ -799,11 +1278,72 @@ var widgetCss = (accent, accentText) => `
|
|
|
799
1278
|
width: 100%;
|
|
800
1279
|
transition: border-color 140ms ease, box-shadow 140ms ease;
|
|
801
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
|
+
}
|
|
802
1292
|
.nexor-chat__input:focus,
|
|
803
1293
|
.nexor-chat__capture input:focus {
|
|
804
1294
|
border-color: ${accent};
|
|
805
1295
|
box-shadow: 0 0 0 3px ${accent}33;
|
|
806
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
|
+
|
|
807
1347
|
.nexor-chat__capture-submit {
|
|
808
1348
|
background: ${accent};
|
|
809
1349
|
color: ${accentText};
|
|
@@ -819,6 +1359,66 @@ var widgetCss = (accent, accentText) => `
|
|
|
819
1359
|
.nexor-chat__capture-submit:active { transform: scale(0.97); }
|
|
820
1360
|
.nexor-chat__capture-submit[disabled] { opacity: 0.6; cursor: not-allowed; }
|
|
821
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
|
+
|
|
822
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 */
|
|
823
1423
|
.nexor-chat__composer {
|
|
824
1424
|
display: flex;
|
|
@@ -826,7 +1426,8 @@ var widgetCss = (accent, accentText) => `
|
|
|
826
1426
|
padding: 10px;
|
|
827
1427
|
background: #fff;
|
|
828
1428
|
border-top: 1px solid rgba(0,0,0,0.06);
|
|
829
|
-
|
|
1429
|
+
/* Pin the send button to the bottom so it stays put as the textarea grows. */
|
|
1430
|
+
align-items: flex-end;
|
|
830
1431
|
}
|
|
831
1432
|
.nexor-chat__send {
|
|
832
1433
|
background: ${accent};
|
|
@@ -838,6 +1439,8 @@ var widgetCss = (accent, accentText) => `
|
|
|
838
1439
|
font-weight: 600;
|
|
839
1440
|
cursor: pointer;
|
|
840
1441
|
min-width: 44px;
|
|
1442
|
+
height: 38px; /* matches the one-line textarea height */
|
|
1443
|
+
flex: none;
|
|
841
1444
|
display: flex;
|
|
842
1445
|
align-items: center;
|
|
843
1446
|
justify-content: center;
|
|
@@ -873,7 +1476,10 @@ var widgetCss = (accent, accentText) => `
|
|
|
873
1476
|
}
|
|
874
1477
|
|
|
875
1478
|
.nexor-chat__footer {
|
|
876
|
-
|
|
1479
|
+
display: flex;
|
|
1480
|
+
align-items: center;
|
|
1481
|
+
justify-content: center;
|
|
1482
|
+
gap: 5px;
|
|
877
1483
|
font-size: 11px;
|
|
878
1484
|
color: #9ca3af;
|
|
879
1485
|
padding: 6px;
|
|
@@ -885,7 +1491,111 @@ var widgetCss = (accent, accentText) => `
|
|
|
885
1491
|
text-decoration: none;
|
|
886
1492
|
font-weight: 600;
|
|
887
1493
|
}
|
|
888
|
-
|
|
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
|
+
}
|
|
889
1599
|
|
|
890
1600
|
/* Respect users who request reduced motion. */
|
|
891
1601
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -907,50 +1617,175 @@ function injectStyles(accent, accentText) {
|
|
|
907
1617
|
style.textContent = widgetCss(accent, accentText);
|
|
908
1618
|
document.head.appendChild(style);
|
|
909
1619
|
}
|
|
910
|
-
|
|
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);
|
|
911
1653
|
const el = document.createElement("button");
|
|
912
1654
|
el.type = "button";
|
|
913
1655
|
el.className = "nexor-chat__launcher";
|
|
914
|
-
el.setAttribute("aria-label", "Open chat");
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
`;
|
|
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;
|
|
921
1662
|
const badge = document.createElement("span");
|
|
922
1663
|
badge.className = "nexor-chat__unread";
|
|
923
1664
|
badge.style.display = "none";
|
|
924
1665
|
el.appendChild(badge);
|
|
925
|
-
|
|
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 };
|
|
926
1704
|
}
|
|
927
1705
|
function buildPanel(cfg) {
|
|
928
1706
|
const el = document.createElement("div");
|
|
929
1707
|
el.className = "nexor-chat__panel";
|
|
930
1708
|
el.setAttribute("role", "dialog");
|
|
931
1709
|
el.setAttribute("aria-label", cfg.title);
|
|
932
|
-
const { header, closeBtn } = buildHeader(cfg);
|
|
933
|
-
const
|
|
934
|
-
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);
|
|
935
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");
|
|
936
1728
|
el.appendChild(header);
|
|
1729
|
+
if (captureView) el.appendChild(captureView);
|
|
1730
|
+
if (contact) el.appendChild(contact.view);
|
|
937
1731
|
el.appendChild(body);
|
|
938
1732
|
el.appendChild(form);
|
|
939
1733
|
if (footer) el.appendChild(footer);
|
|
940
|
-
return {
|
|
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
|
+
};
|
|
941
1752
|
}
|
|
942
1753
|
function buildHeader(cfg) {
|
|
943
1754
|
const header = document.createElement("div");
|
|
944
1755
|
header.className = "nexor-chat__header";
|
|
945
|
-
const
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
+
}
|
|
954
1789
|
const closeBtn = document.createElement("button");
|
|
955
1790
|
closeBtn.type = "button";
|
|
956
1791
|
closeBtn.className = "nexor-chat__close";
|
|
@@ -961,64 +1796,182 @@ function buildHeader(cfg) {
|
|
|
961
1796
|
</svg>
|
|
962
1797
|
`;
|
|
963
1798
|
header.appendChild(closeBtn);
|
|
964
|
-
return { header, closeBtn };
|
|
1799
|
+
return { header, closeBtn, restartBtn, titleEl, subtitleEl, subtitleTextEl };
|
|
965
1800
|
}
|
|
966
|
-
function buildBody(
|
|
1801
|
+
function buildBody() {
|
|
967
1802
|
const body = document.createElement("div");
|
|
968
1803
|
body.className = "nexor-chat__body";
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
}
|
|
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
|
+
};
|
|
977
1819
|
function buildCaptureForm(cfg, inputsOut) {
|
|
978
1820
|
const form = document.createElement("form");
|
|
979
1821
|
form.className = "nexor-chat__capture";
|
|
980
1822
|
form.noValidate = true;
|
|
1823
|
+
let phoneCountry = null;
|
|
981
1824
|
const label = document.createElement("p");
|
|
982
1825
|
label.className = "nexor-chat__capture-label";
|
|
983
1826
|
label.textContent = cfg.capture.label;
|
|
984
1827
|
form.appendChild(label);
|
|
985
1828
|
const fields = cfg.capture.fields;
|
|
1829
|
+
const ph = (f) => cfg.capture.placeholders[f] ?? DEFAULT_FIELD_PLACEHOLDERS[f];
|
|
986
1830
|
if (fields.includes("first_name") || fields.includes("last_name")) {
|
|
987
1831
|
const row = document.createElement("div");
|
|
988
1832
|
row.className = "nexor-chat__capture-row";
|
|
989
1833
|
if (fields.includes("first_name")) {
|
|
990
|
-
inputsOut.first_name = makeInput("first_name", "
|
|
1834
|
+
inputsOut.first_name = makeInput("first_name", ph("first_name"), "text", cfg.lead?.first_name);
|
|
991
1835
|
row.appendChild(inputsOut.first_name);
|
|
992
1836
|
}
|
|
993
1837
|
if (fields.includes("last_name")) {
|
|
994
|
-
inputsOut.last_name = makeInput("last_name", "
|
|
1838
|
+
inputsOut.last_name = makeInput("last_name", ph("last_name"), "text", cfg.lead?.last_name);
|
|
995
1839
|
row.appendChild(inputsOut.last_name);
|
|
996
1840
|
}
|
|
997
1841
|
if (row.childElementCount > 0) form.appendChild(row);
|
|
998
1842
|
}
|
|
999
1843
|
if (fields.includes("email")) {
|
|
1000
|
-
inputsOut.email = makeInput("email", "
|
|
1844
|
+
inputsOut.email = makeInput("email", ph("email"), "email", cfg.lead?.email);
|
|
1001
1845
|
form.appendChild(inputsOut.email);
|
|
1002
1846
|
}
|
|
1003
1847
|
if (fields.includes("phone")) {
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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);
|
|
1007
1858
|
const submit = document.createElement("button");
|
|
1008
1859
|
submit.type = "submit";
|
|
1009
1860
|
submit.className = "nexor-chat__capture-submit";
|
|
1010
1861
|
submit.textContent = cfg.capture.submitLabel;
|
|
1011
1862
|
form.appendChild(submit);
|
|
1012
|
-
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
|
+
};
|
|
1013
1966
|
}
|
|
1014
|
-
function buildComposer() {
|
|
1967
|
+
function buildComposer(cfg) {
|
|
1015
1968
|
const form = document.createElement("form");
|
|
1016
1969
|
form.className = "nexor-chat__composer";
|
|
1017
|
-
const input = document.createElement("
|
|
1018
|
-
input.type = "text";
|
|
1970
|
+
const input = document.createElement("textarea");
|
|
1019
1971
|
input.className = "nexor-chat__input";
|
|
1020
|
-
input.placeholder =
|
|
1021
|
-
input.
|
|
1972
|
+
input.placeholder = cfg.inputPlaceholder;
|
|
1973
|
+
input.rows = 1;
|
|
1974
|
+
input.setAttribute("autocomplete", "off");
|
|
1022
1975
|
const send = document.createElement("button");
|
|
1023
1976
|
send.type = "submit";
|
|
1024
1977
|
send.className = "nexor-chat__send";
|
|
@@ -1033,10 +1986,23 @@ function buildComposer() {
|
|
|
1033
1986
|
form.appendChild(send);
|
|
1034
1987
|
return { form, input, send };
|
|
1035
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>`;
|
|
1036
2002
|
function buildFooter() {
|
|
1037
2003
|
const el = document.createElement("div");
|
|
1038
2004
|
el.className = "nexor-chat__footer";
|
|
1039
|
-
el.innerHTML =
|
|
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>`;
|
|
1040
2006
|
return el;
|
|
1041
2007
|
}
|
|
1042
2008
|
function buildMessage(role, text, ts, locale) {
|
|
@@ -1081,6 +2047,20 @@ function formatRelativeTime(ts, locale, now = Date.now()) {
|
|
|
1081
2047
|
}
|
|
1082
2048
|
}
|
|
1083
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
|
+
}
|
|
1084
2064
|
function buildTypingIndicator() {
|
|
1085
2065
|
const el = document.createElement("div");
|
|
1086
2066
|
el.className = "nexor-chat__typing";
|
|
@@ -1095,9 +2075,6 @@ function makeInput(name, placeholder, type, value) {
|
|
|
1095
2075
|
if (value) i.value = value;
|
|
1096
2076
|
return i;
|
|
1097
2077
|
}
|
|
1098
|
-
function escapeHtml(s) {
|
|
1099
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1100
|
-
}
|
|
1101
2078
|
|
|
1102
2079
|
// src/chat/index.ts
|
|
1103
2080
|
function initChat(client, options) {
|
|
@@ -1107,89 +2084,509 @@ function initChat(client, options) {
|
|
|
1107
2084
|
}
|
|
1108
2085
|
const cfg = applyDefaults(options);
|
|
1109
2086
|
injectStyles(cfg.accentColor, cfg.accentTextColor);
|
|
1110
|
-
|
|
1111
|
-
|
|
2087
|
+
let sessionId = ensureSessionId();
|
|
2088
|
+
let historyKey = `nexor.chat.history.${sessionId}`;
|
|
1112
2089
|
const root = buildRoot(cfg);
|
|
1113
|
-
const launcher = buildLauncher();
|
|
2090
|
+
const launcher = buildLauncher(cfg);
|
|
1114
2091
|
const panel = buildPanel(cfg);
|
|
1115
|
-
root.appendChild(launcher.
|
|
2092
|
+
root.appendChild(launcher.wrap);
|
|
1116
2093
|
root.appendChild(panel.el);
|
|
1117
2094
|
(cfg.container ?? document.body).appendChild(root);
|
|
2095
|
+
const hasFan = launcher.channelButtons.length > 0;
|
|
1118
2096
|
const state = {
|
|
1119
2097
|
isOpen: false,
|
|
2098
|
+
expanded: false,
|
|
2099
|
+
// speed-dial fan visible (touch/click-pinned)
|
|
1120
2100
|
unread: 0,
|
|
1121
2101
|
leadId: void 0,
|
|
1122
2102
|
captured: !needsCapture(cfg),
|
|
1123
2103
|
pendingLead: { ...cfg.lead ?? {} },
|
|
1124
|
-
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
|
|
1125
2130
|
};
|
|
2131
|
+
let configReady;
|
|
1126
2132
|
const send = {
|
|
1127
2133
|
pending: [],
|
|
1128
2134
|
timer: 0,
|
|
1129
2135
|
inFlight: false,
|
|
1130
|
-
typing: null
|
|
2136
|
+
typing: null,
|
|
2137
|
+
splitTimer: 0
|
|
2138
|
+
// staggers multi-bubble bot replies
|
|
1131
2139
|
};
|
|
1132
2140
|
const greeting = { pending: false, timer: 0 };
|
|
1133
2141
|
const GREETING_DELAY_MS = 2e3;
|
|
1134
|
-
launcher.el.addEventListener("click", toggle);
|
|
2142
|
+
launcher.el.addEventListener("click", hasFan ? toggleFan : toggle);
|
|
1135
2143
|
panel.closeBtn.addEventListener("click", close);
|
|
2144
|
+
panel.restartBtn?.addEventListener("click", startOver);
|
|
1136
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
|
+
});
|
|
1137
2153
|
panel.captureForm?.addEventListener("submit", onCaptureSubmit);
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
+
}
|
|
1143
2203
|
scrollToBottom();
|
|
1144
2204
|
}
|
|
2205
|
+
syncComposerLock();
|
|
2206
|
+
configReady = loadServerConfig();
|
|
1145
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
|
+
}
|
|
1146
2222
|
function open() {
|
|
2223
|
+
collapseFan();
|
|
1147
2224
|
if (state.isOpen) return;
|
|
1148
2225
|
state.isOpen = true;
|
|
1149
2226
|
root.setAttribute("data-open", "true");
|
|
1150
2227
|
clearUnread();
|
|
1151
|
-
if (state.
|
|
2228
|
+
if (!state.webchatEnabled) {
|
|
2229
|
+
showDisabled();
|
|
2230
|
+
cfg.onOpen?.();
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
if (state.history.length === 0 && state.captured && canGreet()) {
|
|
1152
2234
|
scheduleGreeting();
|
|
1153
2235
|
}
|
|
1154
|
-
|
|
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();
|
|
2241
|
+
}
|
|
1155
2242
|
cfg.onOpen?.();
|
|
1156
2243
|
}
|
|
1157
2244
|
function close() {
|
|
1158
2245
|
if (!state.isOpen) return;
|
|
1159
2246
|
state.isOpen = false;
|
|
1160
2247
|
root.setAttribute("data-open", "false");
|
|
2248
|
+
if (panel.el.getAttribute("data-view") === "contact") {
|
|
2249
|
+
state.contactChannel = null;
|
|
2250
|
+
setView(baseView());
|
|
2251
|
+
restoreHeader();
|
|
2252
|
+
}
|
|
1161
2253
|
cfg.onClose?.();
|
|
1162
2254
|
}
|
|
2255
|
+
function restoreHeader() {
|
|
2256
|
+
panel.headerTitle.textContent = cfg.title;
|
|
2257
|
+
panel.headerSubtitleText.textContent = cfg.subtitle ?? "";
|
|
2258
|
+
panel.headerSubtitle.hidden = !cfg.subtitle;
|
|
2259
|
+
}
|
|
1163
2260
|
function toggle() {
|
|
1164
2261
|
state.isOpen ? close() : open();
|
|
1165
2262
|
}
|
|
1166
2263
|
function onCaptureSubmit(e) {
|
|
1167
2264
|
e.preventDefault();
|
|
2265
|
+
clearCaptureError();
|
|
1168
2266
|
const collected = readCaptureFields(panel);
|
|
1169
2267
|
const required = cfg.capture.fields;
|
|
2268
|
+
const msgs = cfg.capture.errorMessages;
|
|
1170
2269
|
for (const f of required) {
|
|
1171
|
-
if (!collected[f])
|
|
1172
|
-
|
|
1173
|
-
|
|
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);
|
|
1174
2280
|
}
|
|
2281
|
+
collected.phone = `${country.dial}${digits}`;
|
|
1175
2282
|
}
|
|
1176
2283
|
state.pendingLead = { ...state.pendingLead, ...collected };
|
|
1177
2284
|
state.captured = true;
|
|
1178
|
-
|
|
2285
|
+
persistCapturedLead();
|
|
2286
|
+
setView("chat");
|
|
1179
2287
|
syncComposerLock();
|
|
1180
2288
|
panel.input.focus();
|
|
1181
|
-
if (
|
|
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();
|
|
1182
2572
|
}
|
|
1183
2573
|
function onComposerSubmit(e) {
|
|
1184
2574
|
e.preventDefault();
|
|
1185
2575
|
const text = panel.input.value.trim();
|
|
1186
2576
|
if (!text) return;
|
|
1187
2577
|
panel.input.value = "";
|
|
2578
|
+
autosizeComposer();
|
|
1188
2579
|
queueMessage(text);
|
|
1189
2580
|
}
|
|
2581
|
+
function autosizeComposer() {
|
|
2582
|
+
const el = panel.input;
|
|
2583
|
+
el.style.height = "auto";
|
|
2584
|
+
el.style.height = `${el.scrollHeight + 2}px`;
|
|
2585
|
+
}
|
|
1190
2586
|
function queueMessage(text) {
|
|
1191
2587
|
if (!state.captured) {
|
|
1192
|
-
|
|
2588
|
+
const first = cfg.capture.fields[0];
|
|
2589
|
+
(first && panel.captureInputs[first] || panel.input).focus();
|
|
1193
2590
|
return;
|
|
1194
2591
|
}
|
|
1195
2592
|
cancelGreeting();
|
|
@@ -1197,15 +2594,32 @@ function initChat(client, options) {
|
|
|
1197
2594
|
send.pending.push(text);
|
|
1198
2595
|
scheduleFlush();
|
|
1199
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
|
+
}
|
|
1200
2608
|
function scheduleGreeting() {
|
|
1201
2609
|
if (greeting.pending) return;
|
|
1202
2610
|
greeting.pending = true;
|
|
1203
2611
|
showTyping();
|
|
1204
|
-
greeting.timer = window.setTimeout(() => {
|
|
2612
|
+
greeting.timer = window.setTimeout(async () => {
|
|
2613
|
+
if (!cfg.openingMessage) await configReady;
|
|
2614
|
+
if (!greeting.pending) return;
|
|
1205
2615
|
greeting.pending = false;
|
|
1206
2616
|
greeting.timer = 0;
|
|
1207
2617
|
hideTyping();
|
|
1208
|
-
|
|
2618
|
+
const text = greetingText();
|
|
2619
|
+
if (text) {
|
|
2620
|
+
state.shownOpening = text;
|
|
2621
|
+
appendBot(text);
|
|
2622
|
+
}
|
|
1209
2623
|
}, GREETING_DELAY_MS);
|
|
1210
2624
|
}
|
|
1211
2625
|
function cancelGreeting() {
|
|
@@ -1215,6 +2629,44 @@ function initChat(client, options) {
|
|
|
1215
2629
|
greeting.pending = false;
|
|
1216
2630
|
hideTyping();
|
|
1217
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
|
+
}
|
|
1218
2670
|
function showTyping() {
|
|
1219
2671
|
if (send.typing) {
|
|
1220
2672
|
panel.body.appendChild(send.typing);
|
|
@@ -1237,39 +2689,65 @@ function initChat(client, options) {
|
|
|
1237
2689
|
const batch = send.pending.splice(0, send.pending.length);
|
|
1238
2690
|
const message = batch.join("\n");
|
|
1239
2691
|
send.inFlight = true;
|
|
2692
|
+
if (state.leadCreating) {
|
|
2693
|
+
try {
|
|
2694
|
+
await state.leadCreating;
|
|
2695
|
+
} catch {
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
const turnResetsEpisode = state.resetEpisode;
|
|
1240
2699
|
try {
|
|
1241
2700
|
const res = await client.chat(
|
|
1242
2701
|
{
|
|
1243
2702
|
workflow_id: cfg.workflowId,
|
|
1244
2703
|
session_id: sessionId,
|
|
1245
2704
|
message,
|
|
2705
|
+
// Additive page/instance prompt injection (API appends to the
|
|
2706
|
+
// workflow persona). Per route, the host passes different values.
|
|
1246
2707
|
system_prompt: cfg.systemPrompt,
|
|
1247
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,
|
|
1248
2715
|
lead: Object.keys(state.pendingLead).length ? state.pendingLead : void 0,
|
|
1249
|
-
metadata
|
|
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
|
|
1250
2719
|
},
|
|
1251
2720
|
// A chat turn is NOT idempotent (re-sending re-runs the LLM and
|
|
1252
2721
|
// re-inserts the message). Never retry; just give it a generous timeout.
|
|
1253
2722
|
{ timeoutMs: cfg.requestTimeoutMs, maxRetries: 0 }
|
|
1254
2723
|
);
|
|
2724
|
+
if (turnResetsEpisode) state.resetEpisode = false;
|
|
1255
2725
|
if (res.lead_id && !state.leadId) {
|
|
1256
2726
|
state.leadId = res.lead_id;
|
|
1257
2727
|
cfg.onLeadCaptured?.(state.leadId);
|
|
1258
2728
|
}
|
|
2729
|
+
if (res.matched_existing && state.freshLocalSession && !state.episodeChoiceOffered && !turnResetsEpisode) {
|
|
2730
|
+
state.episodeChoiceOffered = true;
|
|
2731
|
+
renderResumeBanner(true);
|
|
2732
|
+
}
|
|
1259
2733
|
const reply = (res.reply ?? "").trim();
|
|
1260
2734
|
hideTyping();
|
|
1261
2735
|
if (reply) {
|
|
1262
|
-
|
|
2736
|
+
appendBotReply(reply);
|
|
1263
2737
|
} else {
|
|
1264
2738
|
appendSystem(cfg.errorText);
|
|
1265
2739
|
cfg.onError?.(new Error("Nexor SDK: empty reply from server"));
|
|
1266
2740
|
}
|
|
1267
2741
|
} catch (err) {
|
|
1268
2742
|
hideTyping();
|
|
1269
|
-
|
|
2743
|
+
if (/web chat is not enabled/i.test(err?.message || "")) {
|
|
2744
|
+
applyServerConfig(false, state.serverInitialMessage);
|
|
2745
|
+
} else {
|
|
2746
|
+
appendSystem(cfg.errorText);
|
|
2747
|
+
}
|
|
1270
2748
|
cfg.onError?.(err);
|
|
1271
2749
|
} finally {
|
|
1272
|
-
hideTyping();
|
|
2750
|
+
if (!send.splitTimer) hideTyping();
|
|
1273
2751
|
send.inFlight = false;
|
|
1274
2752
|
panel.input.focus();
|
|
1275
2753
|
if (send.pending.length) scheduleFlush();
|
|
@@ -1282,37 +2760,84 @@ function initChat(client, options) {
|
|
|
1282
2760
|
saveHistory();
|
|
1283
2761
|
cfg.onMessage?.({ role: "user", text });
|
|
1284
2762
|
}
|
|
1285
|
-
function appendBot(text) {
|
|
1286
|
-
const ts = Date.now();
|
|
2763
|
+
function appendBot(text, showTime = true) {
|
|
2764
|
+
const ts = showTime ? Date.now() : void 0;
|
|
1287
2765
|
appendBubble("bot", text, ts);
|
|
1288
2766
|
state.history.push({ role: "bot", text, ts });
|
|
1289
2767
|
saveHistory();
|
|
1290
2768
|
if (!state.isOpen) bumpUnread();
|
|
1291
2769
|
cfg.onMessage?.({ role: "bot", text });
|
|
1292
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
|
+
}
|
|
1293
2808
|
function saveHistory() {
|
|
1294
2809
|
try {
|
|
1295
2810
|
window.localStorage.setItem(
|
|
1296
2811
|
historyKey,
|
|
1297
|
-
|
|
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
|
+
})
|
|
1298
2819
|
);
|
|
1299
2820
|
touchSession();
|
|
1300
2821
|
} catch {
|
|
1301
2822
|
}
|
|
1302
2823
|
}
|
|
1303
|
-
function
|
|
2824
|
+
function loadPersisted() {
|
|
2825
|
+
const empty = { history: [], lead: void 0 };
|
|
1304
2826
|
try {
|
|
1305
2827
|
const raw = window.localStorage.getItem(historyKey);
|
|
1306
|
-
if (!raw) return
|
|
2828
|
+
if (!raw) return empty;
|
|
1307
2829
|
const o = JSON.parse(raw);
|
|
1308
|
-
if (!o || !Array.isArray(o.history)) return
|
|
2830
|
+
if (!o || !Array.isArray(o.history)) return empty;
|
|
1309
2831
|
if (Date.now() - (o.ts || 0) > SESSION_RETENTION_MS) {
|
|
1310
2832
|
window.localStorage.removeItem(historyKey);
|
|
1311
|
-
return
|
|
2833
|
+
return empty;
|
|
1312
2834
|
}
|
|
1313
|
-
return
|
|
2835
|
+
return {
|
|
2836
|
+
history: o.history,
|
|
2837
|
+
lead: o.lead && typeof o.lead === "object" ? o.lead : void 0
|
|
2838
|
+
};
|
|
1314
2839
|
} catch {
|
|
1315
|
-
return
|
|
2840
|
+
return empty;
|
|
1316
2841
|
}
|
|
1317
2842
|
}
|
|
1318
2843
|
function appendSystem(text) {
|
|
@@ -1349,7 +2874,7 @@ function initChat(client, options) {
|
|
|
1349
2874
|
const lock = !state.captured;
|
|
1350
2875
|
panel.input.disabled = lock;
|
|
1351
2876
|
panel.send.disabled = lock;
|
|
1352
|
-
panel.input.placeholder = lock ? "Complete the form above to start\u2026" :
|
|
2877
|
+
panel.input.placeholder = lock ? "Complete the form above to start\u2026" : cfg.inputPlaceholder;
|
|
1353
2878
|
}
|
|
1354
2879
|
return {
|
|
1355
2880
|
open,
|
|
@@ -1363,8 +2888,10 @@ function initChat(client, options) {
|
|
|
1363
2888
|
},
|
|
1364
2889
|
destroy: () => {
|
|
1365
2890
|
if (send.timer) window.clearTimeout(send.timer);
|
|
2891
|
+
if (send.splitTimer) window.clearTimeout(send.splitTimer);
|
|
1366
2892
|
if (greeting.timer) window.clearTimeout(greeting.timer);
|
|
1367
2893
|
window.clearInterval(refreshTimer);
|
|
2894
|
+
if (onDocPointerDown) document.removeEventListener("pointerdown", onDocPointerDown);
|
|
1368
2895
|
root.remove();
|
|
1369
2896
|
},
|
|
1370
2897
|
getSessionId: () => sessionId
|
|
@@ -1388,7 +2915,7 @@ function readCaptureFields(panel) {
|
|
|
1388
2915
|
if (inputs.first_name) out.first_name = inputs.first_name.value.trim();
|
|
1389
2916
|
if (inputs.last_name) out.last_name = inputs.last_name.value.trim();
|
|
1390
2917
|
if (inputs.email) out.email = inputs.email.value.trim();
|
|
1391
|
-
if (inputs.phone) out.phone = inputs.phone.value.
|
|
2918
|
+
if (inputs.phone) out.phone = inputs.phone.value.replace(/\D/g, "");
|
|
1392
2919
|
return out;
|
|
1393
2920
|
}
|
|
1394
2921
|
|