@meetelise/chat 1.55.3 → 1.57.0

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.
@@ -76,6 +76,7 @@ import {
76
76
  shouldOpenTourSchedulerFromUrl,
77
77
  storeGoogleReserveReferral,
78
78
  } from "../googleReserve";
79
+ import { consumeReopenChatAfterNavigation } from "../webchatNavigation";
79
80
 
80
81
  @customElement("me-chat")
81
82
  export class MEChat extends LitElement {
@@ -136,6 +137,19 @@ export class MEChat extends LitElement {
136
137
  private orgSlug = "";
137
138
  @property({ type: Boolean })
138
139
  private centralConvo = false;
140
+ /**
141
+ * Org-level webchat only: the building the snippet names (`building` in
142
+ * `MEChat.start`). It doesn't change config or the conversation, which stay
143
+ * org-anchored; its id is sent with visitor messages as `declaredBuildingId`.
144
+ */
145
+ @property({ type: String })
146
+ private declaredBuildingSlug = "";
147
+ /**
148
+ * Org-level webchat (`centralConvo` started without a building). Stays true
149
+ * after the widget adopts the org's triage general building from
150
+ * /webchat/init, which sets `buildingSlug`.
151
+ */
152
+ private orgLevel = false;
139
153
  @property({ type: Object })
140
154
  launcherStyles: StyleInfo = {};
141
155
  @property()
@@ -284,26 +298,30 @@ export class MEChat extends LitElement {
284
298
  private setupWebchat = async (): Promise<void> => {
285
299
  if (!this.orgSlug) return;
286
300
  if (!this.centralConvo && !this.buildingSlug) return;
301
+ if (this.centralConvo && !this.buildingSlug) this.orgLevel = true;
287
302
  const buildingWebchatView = this.centralConvo
288
303
  ? await fetchOrgCentralWebchatView(this.orgSlug)
289
304
  : await fetchBuildingWebchatView(this.orgSlug, this.buildingSlug);
290
305
 
291
- if (Math.random() < 0.2) {
306
+ // Both post per-building data, keyed by the building slug.
307
+ if (!this.isOrgMode()) {
308
+ if (Math.random() < 0.2) {
309
+ try {
310
+ updateRentgrataToken(this.buildingSlug, this.orgSlug);
311
+ } catch (e) {
312
+ // eslint-disable-next-line no-console
313
+ console.warn("Error updating rentgrata token", e);
314
+ }
315
+ }
316
+
292
317
  try {
293
- updateRentgrataToken(this.buildingSlug, this.orgSlug);
318
+ logCdnVersion(this.orgSlug, this.buildingSlug);
294
319
  } catch (e) {
295
320
  // eslint-disable-next-line no-console
296
- console.warn("Error updating rentgrata token", e);
321
+ console.warn("Error logging CDN version", e);
297
322
  }
298
323
  }
299
324
 
300
- try {
301
- logCdnVersion(this.orgSlug, this.buildingSlug);
302
- } catch (e) {
303
- // eslint-disable-next-line no-console
304
- console.warn("Error logging CDN version", e);
305
- }
306
-
307
325
  const shouldShowWebchat = await getShouldShowWebchat(
308
326
  buildingWebchatView,
309
327
  this.buildingSlug,
@@ -325,6 +343,9 @@ export class MEChat extends LitElement {
325
343
  }
326
344
  this.buildingWebchatView = buildingWebchatView;
327
345
 
346
+ // A "Take me there" click on the previous page.
347
+ const reopenAfterNavigation = consumeReopenChatAfterNavigation();
348
+
328
349
  await this.initializeChatVariables();
329
350
  await this.handleChatInitializeAnalytics();
330
351
  await this.setBuildingDerivedInfo();
@@ -333,7 +354,13 @@ export class MEChat extends LitElement {
333
354
 
334
355
  storeGoogleReserveReferral(this.buildingWebchatView?.id ?? 0);
335
356
 
336
- if (
357
+ if (reopenAfterNavigation) {
358
+ // Reopen the chat the visitor was using. initializeChatVariables has
359
+ // already loaded the saved conversation, so it is drawn on open.
360
+ this.displayPubnubChat = true;
361
+ this.hideLauncher = true;
362
+ this.hasMounted = true;
363
+ } else if (
337
364
  shouldOpenTourSchedulerFromUrl() &&
338
365
  (this.enabledChatWidgets.sstDesktop || this.enabledChatWidgets.sstMobile)
339
366
  ) {
@@ -355,7 +382,9 @@ export class MEChat extends LitElement {
355
382
  setBuildingDerivedInfo = async (): Promise<void> => {
356
383
  const buildingDetails = this.buildingWebchatView;
357
384
  if (!buildingDetails) return;
358
- if (buildingDetails && this.orgSlug) {
385
+ // Org-level webchat runs on the org's own pages, so it leaves their links,
386
+ // forms and phone numbers alone rather than pointing them at its building.
387
+ if (buildingDetails && this.orgSlug && !this.orgLevel) {
359
388
  overrideContactUsForm(
360
389
  this.orgSlug,
361
390
  this.LeadSourceClient?.chatId ?? null,
@@ -385,7 +414,10 @@ export class MEChat extends LitElement {
385
414
  bedroomPreferenceRequired,
386
415
  brokerIntakeEnabled,
387
416
  ] = await Promise.all([
388
- fetchLeadSources(this.orgSlug, this.buildingSlug),
417
+ // Per-building endpoint; org mode has no building to ask about.
418
+ this.isOrgMode()
419
+ ? Promise.resolve([])
420
+ : fetchLeadSources(this.orgSlug, this.buildingSlug),
389
421
  fetchFeatureFlagUseApplicationsLinkReplacement(this.buildingSlug),
390
422
  fetchBuildingAip(this.buildingSlug),
391
423
  fetchTrackedRolloutBedroomPreferenceRequired(this.buildingSlug),
@@ -405,7 +437,10 @@ export class MEChat extends LitElement {
405
437
  this.brokerIntakeEnabled = brokerIntakeEnabled;
406
438
 
407
439
  // The backend is cached for ~4 hours, so falling back to the existing api request until the cache propagates all the changes
408
- const buildingPhoneNumber = buildingDetails.textWithUsPhoneNumber
440
+ // Org-level webchat never offers Text Us, so it needs no number for it.
441
+ const buildingPhoneNumber = this.orgLevel
442
+ ? null
443
+ : buildingDetails.textWithUsPhoneNumber
409
444
  ? buildingDetails.textWithUsPhoneNumber
410
445
  : await getBuildingPhoneNumber(this.buildingSlug);
411
446
 
@@ -435,9 +470,15 @@ export class MEChat extends LitElement {
435
470
  calcMobile: shouldShowCalcMobile,
436
471
  };
437
472
 
438
- this.enabledChatWidgets = getWidgetOverrides(baseEnabledWidgets);
473
+ this.enabledChatWidgets = this.withOrgLevelContactOptions(
474
+ getWidgetOverrides(baseEnabledWidgets),
475
+ this.buildingWebchatView?.phoneNumber ?? ""
476
+ );
439
477
 
440
- if (
478
+ if (this.orgLevel) {
479
+ // Org-level webchat leaves the org site's tour links alone too; they'd
480
+ // otherwise open the triage general building's scheduler.
481
+ } else if (
441
482
  this.enabledChatWidgets.sstDesktop ||
442
483
  this.enabledChatWidgets.sstMobile ||
443
484
  this.orgSlug === "radco"
@@ -501,7 +542,8 @@ export class MEChat extends LitElement {
501
542
 
502
543
  const buildingsPhoneNumber = this.buildingWebchatView?.phoneNumber ?? "";
503
544
  let phoneNumberForSource = null;
504
- if (featureFlagUseDNI) {
545
+ // Org-level webchat calls its building's own number, never a DNI number.
546
+ if (featureFlagUseDNI && !this.orgLevel) {
505
547
  phoneNumberForSource = await fetchPhoneNumberFromSource(
506
548
  this.buildingSlug,
507
549
  this.LeadSourceClient?.leadSource ?? null
@@ -560,12 +602,18 @@ export class MEChat extends LitElement {
560
602
  this.leadSources.push(currentLeadSource);
561
603
  }
562
604
  }
563
- getRawAvailabilities(buildingDetails.id); // we're not using this here, just want to cache the result
605
+ if (!this.isOrgMode()) {
606
+ getRawAvailabilities(buildingDetails.id); // we're not using this here, just want to cache the result
607
+ }
564
608
  const isApplicationLinkReplacementEnabled =
565
609
  (featureFlagUseApplicationsLinkReplacement ||
566
610
  this.buildingWebchatView?.isAip) &&
567
611
  this.buildingWebchatView?.isLiveOnApplications;
568
- if (isApplicationLinkReplacementEnabled && this.buildingSlug) {
612
+ if (
613
+ isApplicationLinkReplacementEnabled &&
614
+ this.buildingSlug &&
615
+ !this.orgLevel
616
+ ) {
569
617
  const totalReplacements = replaceSelectButtonsWithNewLink(
570
618
  `https://applications.eliseai.com/building/${this.buildingSlug}/units`,
571
619
  this.orgSlug,
@@ -586,7 +634,7 @@ export class MEChat extends LitElement {
586
634
  }
587
635
  }
588
636
 
589
- if (this.buildingSlug && this.orgSlug) {
637
+ if (this.buildingSlug && this.orgSlug && !this.orgLevel) {
590
638
  try {
591
639
  replaceOutdatedApplicationLinks(this.buildingSlug);
592
640
  } catch {
@@ -597,6 +645,7 @@ export class MEChat extends LitElement {
597
645
 
598
646
  if (
599
647
  this.buildingSlug &&
648
+ !this.orgLevel &&
600
649
  !!this.buildingWebchatView?.isLiveOnApplications &&
601
650
  this.buildingWebchatView?.applicationLink &&
602
651
  shouldOverwriteCheckAvail(this.orgSlug)
@@ -613,9 +662,10 @@ export class MEChat extends LitElement {
613
662
  if (!this.buildingWebchatView) return;
614
663
 
615
664
  // important to distinguish between a user who has never opened the chat and a user who has opened the chat but closed it
616
- const currentChatStorageKey = getChatStorageKey(this.buildingSlug);
665
+ const chatStorageSlug = this.chatStorageSlug();
666
+ const currentChatStorageKey = getChatStorageKey(chatStorageSlug);
617
667
  const isReturningUser = isChatKeyValid(
618
- this.buildingSlug,
668
+ chatStorageSlug,
619
669
  currentChatStorageKey
620
670
  );
621
671
 
@@ -623,7 +673,7 @@ export class MEChat extends LitElement {
623
673
  // temporary client-generated one (will be overwritten by server session below).
624
674
  const updatedChatStorageKey = isReturningUser
625
675
  ? currentChatStorageKey
626
- : createChatStorageKey(this.buildingSlug, false);
676
+ : createChatStorageKey(chatStorageSlug, false);
627
677
 
628
678
  if (!updatedChatStorageKey.leadId) {
629
679
  throw new Error("Lead ID is null");
@@ -646,6 +696,17 @@ export class MEChat extends LitElement {
646
696
  this.LeadSourceClient,
647
697
  this.widgetType
648
698
  );
699
+ this.myPubnub.chatStorageSlug = chatStorageSlug;
700
+ if (this.centralConvo) {
701
+ const myPubnub = this.myPubnub;
702
+ myPubnub.centralConvo = true;
703
+ // Not awaited: only needed once the visitor sends a message.
704
+ void this.fetchDeclaredBuildingId().then((id) => {
705
+ myPubnub.declaredBuildingId = id;
706
+ });
707
+ } else {
708
+ this.myPubnub.declaredBuildingId = this.buildingWebchatView.id;
709
+ }
649
710
 
650
711
  // Always create/resume a server-side session so the client has a fresh
651
712
  // sessionToken and never falls back to the insecure V1 token endpoint.
@@ -658,10 +719,13 @@ export class MEChat extends LitElement {
658
719
  if (session) {
659
720
  // Persist the server-provided leadId to localStorage, preserving initiatedChat flag
660
721
  createChatStorageKey(
661
- this.buildingSlug,
722
+ chatStorageSlug,
662
723
  updatedChatStorageKey.initiatedChat,
663
724
  session.lead_id
664
725
  );
726
+ if (this.isOrgMode() && session.building_slug) {
727
+ await this.adoptBuilding(session.building_slug);
728
+ }
665
729
  }
666
730
 
667
731
  this.LeadSourceClient.chatId = this.myPubnub.channel;
@@ -693,12 +757,77 @@ export class MEChat extends LitElement {
693
757
  }
694
758
  // Re-read from localStorage so initializePubnub gets the server-provided
695
759
  // leadId (not the stale reference captured before initSession).
696
- const finalChatStorageKey = getChatStorageKey(this.buildingSlug);
760
+ const finalChatStorageKey = getChatStorageKey(chatStorageSlug);
697
761
  if (finalChatStorageKey.initiatedChat) {
698
762
  await this.myPubnub.initializePubnub(finalChatStorageKey);
699
763
  }
700
764
  };
701
765
 
766
+ /**
767
+ * Org-level webchat with no building: config comes from the org, and there is
768
+ * no building slug for the per-building endpoints.
769
+ */
770
+ private isOrgMode = (): boolean => this.centralConvo && !this.buildingSlug;
771
+
772
+ /** Where the conversation is saved: org-level webchat keeps one per org. */
773
+ private chatStorageSlug = (): string =>
774
+ this.orgLevel ? "" : this.buildingSlug;
775
+
776
+ /**
777
+ * Org-level webchat: /webchat/init chose the org's triage general building,
778
+ * so run as a widget for that building from here on (its config, and its
779
+ * slug for the per-building endpoints). The conversation stays the org-level
780
+ * one /webchat/init returned. If its config can't be loaded, stay in org mode.
781
+ */
782
+ private adoptBuilding = async (buildingSlug: string): Promise<void> => {
783
+ try {
784
+ const view = await fetchBuildingWebchatView(this.orgSlug, buildingSlug);
785
+ if (!view?.id) return;
786
+ this.buildingSlug = buildingSlug;
787
+ this.buildingWebchatView = view;
788
+ this.myPubnub?.adoptBuilding(buildingSlug, view);
789
+ } catch (e) {
790
+ // eslint-disable-next-line no-console
791
+ console.warn("Error loading the org-level webchat building", e);
792
+ }
793
+ };
794
+
795
+ /**
796
+ * Org-level webchat offers no Email or Text Us, and offers Call Us only with
797
+ * its building's number (the org's triage line).
798
+ */
799
+ private withOrgLevelContactOptions = <
800
+ T extends typeof this.enabledChatWidgets
801
+ >(
802
+ widgets: T,
803
+ callNumber: string
804
+ ): T => {
805
+ if (!this.orgLevel) return widgets;
806
+ return {
807
+ ...widgets,
808
+ emailDesktop: false,
809
+ emailMobile: false,
810
+ textDesktop: false,
811
+ textMobile: false,
812
+ callDesktop: widgets.callDesktop && !!callNumber,
813
+ callMobile: widgets.callMobile && !!callNumber,
814
+ };
815
+ };
816
+
817
+ /** The numeric id of the snippet's building in org-level webchat, if it names one. */
818
+ private fetchDeclaredBuildingId = async (): Promise<number | null> => {
819
+ if (!this.declaredBuildingSlug || !this.orgSlug) return null;
820
+ try {
821
+ const view = await fetchBuildingWebchatView(
822
+ this.orgSlug,
823
+ this.declaredBuildingSlug
824
+ );
825
+ return typeof view?.id === "number" && view.id > 0 ? view.id : null;
826
+ } catch {
827
+ return null;
828
+ }
829
+ };
830
+
702
831
  private shouldAutoOpenChat = (buildingHasAutoOpen: boolean): boolean => {
703
832
  const alreadyAutoOpenedTimestamp = sessionStorage.getItem(
704
833
  "alreadyAutoOpenedTimestamp"
@@ -728,7 +857,9 @@ export class MEChat extends LitElement {
728
857
 
729
858
  public async restartConversation(): Promise<void> {
730
859
  this.myPubnub?.handleDisconnect();
731
- clearChatStorageKey(this.buildingSlug);
860
+ clearChatStorageKey(this.chatStorageSlug());
861
+ // Org-level webchat asks /webchat/init for its building again.
862
+ if (this.orgLevel) this.buildingSlug = "";
732
863
  this.myPubnub = null;
733
864
  this.displayPubnubChat = false;
734
865
  await this.setupWebchat();
@@ -981,6 +1112,7 @@ export class MEChat extends LitElement {
981
1112
  .myPubnub=${this.myPubnub}
982
1113
  .orgSlug=${this.orgSlug}
983
1114
  .buildingSlug=${this.buildingSlug}
1115
+ .centralConvo=${this.centralConvo}
984
1116
  .widgetType=${WidgetType.Default}
985
1117
  .building=${this.buildingWebchatView}
986
1118
  .primaryColor=${this.primaryColor}
@@ -6,6 +6,7 @@ import MyPubnub, {
6
6
  SimpleMessageTypes,
7
7
  SimpleChatMessage,
8
8
  isSimpleMediaChatMessage,
9
+ isSimpleNavigationChatMessage,
9
10
  isSimpleTextChatMessage,
10
11
  } from "../MyPubnub";
11
12
  import {
@@ -21,6 +22,7 @@ import { leasingChatStyles } from "./leasing-chat-styles";
21
22
  import { pubnubChatStyles } from "./pubnub-chat-styles";
22
23
  import "./pubnub-message";
23
24
  import "./pubnub-media";
25
+ import "./pubnub-navigation";
24
26
  import classNames from "classnames";
25
27
  import { differenceInDays } from "date-fns";
26
28
  import { WidgetType } from "../main/MEChat";
@@ -79,6 +81,10 @@ export class PubnubChat extends LitElement {
79
81
  @property({ attribute: true })
80
82
  buildingSlug: string | undefined;
81
83
 
84
+ /** Org-level webchat, which has no building slug. */
85
+ @property({ attribute: true })
86
+ centralConvo = false;
87
+
82
88
  @property({ attribute: true })
83
89
  widgetType: WidgetType = WidgetType.Default;
84
90
 
@@ -243,7 +249,7 @@ export class PubnubChat extends LitElement {
243
249
  };
244
250
 
245
251
  render(): TemplateResult {
246
- if (!this.buildingSlug) {
252
+ if (!this.buildingSlug && !this.centralConvo) {
247
253
  return html``; // error should be here
248
254
  }
249
255
 
@@ -376,10 +382,14 @@ export class PubnubChat extends LitElement {
376
382
  role="article"
377
383
  aria-label="${message.isLeadMessage
378
384
  ? "You"
379
- : "Elise"}: ${isSimpleTextChatMessage(message)
380
- ? message.message
381
- : "Media message"}"
385
+ : "Elise"}: ${messageAriaLabel(message)}"
382
386
  >
387
+ ${isSimpleNavigationChatMessage(message)
388
+ ? html`<pubnub-navigation
389
+ .message=${message}
390
+ .onMount=${() => this.scrollToChatBottom()}
391
+ ></pubnub-navigation>`
392
+ : ""}
383
393
  ${isSimpleMediaChatMessage(message)
384
394
  ? html` <pubnub-media
385
395
  .message=${message}
@@ -551,10 +561,14 @@ export class PubnubChat extends LitElement {
551
561
  role="article"
552
562
  aria-label="${message.isLeadMessage
553
563
  ? "You"
554
- : "Elise"}: ${isSimpleTextChatMessage(message)
555
- ? message.message
556
- : "Media message"}"
564
+ : "Elise"}: ${messageAriaLabel(message)}"
557
565
  >
566
+ ${isSimpleNavigationChatMessage(message)
567
+ ? html`<pubnub-navigation
568
+ .message=${message}
569
+ .onMount=${() => this.scrollToChatBottom()}
570
+ ></pubnub-navigation>`
571
+ : ""}
558
572
  ${isSimpleMediaChatMessage(message)
559
573
  ? html` <pubnub-media
560
574
  .message=${message}
@@ -750,10 +764,14 @@ export class PubnubChat extends LitElement {
750
764
  role="article"
751
765
  aria-label="${message.isLeadMessage
752
766
  ? "You"
753
- : "Elise"}: ${isSimpleTextChatMessage(message)
754
- ? message.message
755
- : "Media message"}"
767
+ : "Elise"}: ${messageAriaLabel(message)}"
756
768
  >
769
+ ${isSimpleNavigationChatMessage(message)
770
+ ? html`<pubnub-navigation
771
+ .message=${message}
772
+ .onMount=${() => this.scrollToChatBottom()}
773
+ ></pubnub-navigation>`
774
+ : ""}
757
775
  ${isSimpleMediaChatMessage(message)
758
776
  ? html` <pubnub-media
759
777
  .message=${message}
@@ -851,3 +869,11 @@ export class PubnubChat extends LitElement {
851
869
  `;
852
870
  }
853
871
  }
872
+
873
+ const messageAriaLabel = (message: SimpleChatMessage): string => {
874
+ if (isSimpleTextChatMessage(message)) return message.message;
875
+ if (isSimpleNavigationChatMessage(message)) {
876
+ return `Take me to ${message.navigation.label}`;
877
+ }
878
+ return "Media message";
879
+ };
@@ -0,0 +1,73 @@
1
+ import { css, html, LitElement, TemplateResult } from "lit";
2
+ import { customElement, property } from "lit/decorators.js";
3
+ import { classMap } from "lit/directives/class-map.js";
4
+ import { SimpleNavigationChatMessage } from "../MyPubnub";
5
+ import { isMobile } from "../utils";
6
+ import { takeMeThere } from "../webchatNavigation";
7
+ import { InputStyles } from "./actions/InputStyles";
8
+ import { pubnubChatStyles } from "./pubnub-chat-styles";
9
+
10
+ /** The "Take me to {label}" button the agent sends after a reply. */
11
+ @customElement("pubnub-navigation")
12
+ export class PubnubNavigation extends LitElement {
13
+ static styles = [
14
+ InputStyles,
15
+ pubnubChatStyles,
16
+ css`
17
+ .navigation-button {
18
+ cursor: pointer;
19
+ border: none;
20
+ border-radius: 8px;
21
+ padding: 8px 12px;
22
+ background: black;
23
+ color: white;
24
+ font-family: inherit;
25
+ text-align: left;
26
+ line-height: 1.4;
27
+ }
28
+ .navigation-button:hover {
29
+ opacity: 0.85;
30
+ }
31
+ .navigation-button:focus-visible {
32
+ outline: 2px solid #4d90fe;
33
+ outline-offset: 2px;
34
+ }
35
+ `,
36
+ ];
37
+
38
+ @property({ attribute: false })
39
+ message: SimpleNavigationChatMessage | undefined;
40
+
41
+ @property({ attribute: false })
42
+ onMount: () => void = () => ({});
43
+
44
+ /** Overridable in tests; navigates the same tab by default. */
45
+ @property({ attribute: false })
46
+ navigate: (url: string) => void = (url) => window.location.assign(url);
47
+
48
+ updated(): void {
49
+ this.onMount();
50
+ }
51
+
52
+ render(): TemplateResult {
53
+ if (!this.message) return html``;
54
+ const { navigation } = this.message;
55
+ return html`<button
56
+ type="button"
57
+ class=${classMap({
58
+ ["navigation-button"]: true,
59
+ ["webchat-font__desktop"]: !isMobile(),
60
+ ["webchat-font__mobile"]: isMobile(),
61
+ })}
62
+ @click=${() => takeMeThere(navigation, this.navigate)}
63
+ >
64
+ Take me to ${navigation.label}
65
+ </button>`;
66
+ }
67
+ }
68
+
69
+ declare global {
70
+ interface HTMLElementTagNameMap {
71
+ "pubnub-navigation": PubnubNavigation;
72
+ }
73
+ }
@@ -85,6 +85,43 @@ it("sends a broker referral with the client as the prospect and the broker field
85
85
  expect(JSON.stringify(event)).not.to.contain("bea@realty.example.com");
86
86
  });
87
87
 
88
+ it("sends the broker's phone on a referral email when they gave one", async () => {
89
+ const requests = captureRequests();
90
+ const emailUs = createForm({
91
+ brokerIntakeEnabled: true,
92
+ isBrokerReferral: true,
93
+ ...brokerFields,
94
+ brokerPhone: "(312) 555-0199",
95
+ brokerPhoneE164: "+13125550199",
96
+ brokerPhoneIsValid: true,
97
+ });
98
+
99
+ const submission = emailUs.onClick();
100
+
101
+ expect(requests).to.have.length(1);
102
+ expect(JSON.parse(requests[0].requestBody ?? "{}")).to.include({
103
+ broker_phone: "+13125550199",
104
+ });
105
+ requests[0].respond(200, { "Content-Type": "application/json" }, "{}");
106
+ await submission;
107
+ });
108
+
109
+ it("won't send a referral email with an invalid broker phone", async () => {
110
+ const requests = captureRequests();
111
+ const emailUs = createForm({
112
+ brokerIntakeEnabled: true,
113
+ isBrokerReferral: true,
114
+ ...brokerFields,
115
+ brokerPhone: "555",
116
+ brokerPhoneE164: null,
117
+ brokerPhoneIsValid: false,
118
+ });
119
+
120
+ await emailUs.onClick();
121
+
122
+ expect(requests).to.have.length(0);
123
+ });
124
+
88
125
  it("sends no broker fields when broker intake is off, whatever the form holds", async () => {
89
126
  const requests = captureRequests();
90
127
  const emailUs = createForm({
@@ -224,9 +261,15 @@ it("moves what a broker typed into their own details when they tick the referral
224
261
  fill("Email", "bea@realty.example.com");
225
262
  await emailUs.updateComplete;
226
263
 
227
- const checkbox = emailUs.shadowRoot?.querySelector(
228
- "#broker-referral-toggle"
229
- ) as HTMLInputElement;
264
+ // The switch moves when ticked (under Brokerage), so find it again for each click.
265
+ const checkbox = {
266
+ click: () =>
267
+ (
268
+ emailUs.shadowRoot?.querySelector(
269
+ "#broker-referral-toggle"
270
+ ) as HTMLInputElement
271
+ ).click(),
272
+ };
230
273
  checkbox.click();
231
274
  await emailUs.updateComplete;
232
275
 
@@ -257,9 +300,15 @@ it("keeps a filled client when the referral box is unticked and ticked again", a
257
300
  Object.assign(emailUs, { brokerIntakeEnabled: true, buildingId: 445038 });
258
301
  document.body.appendChild(emailUs);
259
302
  await emailUs.updateComplete;
260
- const checkbox = emailUs.shadowRoot?.querySelector(
261
- "#broker-referral-toggle"
262
- ) as HTMLInputElement;
303
+ // The switch moves when ticked (under Brokerage), so find it again for each click.
304
+ const checkbox = {
305
+ click: () =>
306
+ (
307
+ emailUs.shadowRoot?.querySelector(
308
+ "#broker-referral-toggle"
309
+ ) as HTMLInputElement
310
+ ).click(),
311
+ };
263
312
  checkbox.click();
264
313
  await emailUs.updateComplete;
265
314
  Object.assign(emailUs, {
@@ -357,6 +406,44 @@ it("books a broker's tour with the client as the prospect and the broker fields"
357
406
  expect(JSON.stringify(event)).not.to.contain("bea@realty.example.com");
358
407
  });
359
408
 
409
+ it("sends the broker's phone on a referral tour when they gave one", async () => {
410
+ const requests = captureRequests();
411
+ const tour = createTour({
412
+ brokerIntakeEnabled: true,
413
+ isBrokerReferral: true,
414
+ ...brokerFields,
415
+ brokerPhone: "(312) 555-0199",
416
+ brokerPhoneE164: "+13125550199",
417
+ brokerPhoneIsValid: true,
418
+ });
419
+
420
+ const submission = tour.submit();
421
+
422
+ expect(requests).to.have.length(1);
423
+ expect(JSON.parse(requests[0].requestBody ?? "{}")).to.include({
424
+ phone_number: "+13125550100",
425
+ broker_phone: "+13125550199",
426
+ });
427
+ requests[0].respond(200, { "Content-Type": "application/json" }, "{}");
428
+ await submission;
429
+ });
430
+
431
+ it("won't book a referral tour with an invalid broker phone", async () => {
432
+ const requests = captureRequests();
433
+ const tour = createTour({
434
+ brokerIntakeEnabled: true,
435
+ isBrokerReferral: true,
436
+ ...brokerFields,
437
+ brokerPhone: "555",
438
+ brokerPhoneE164: null,
439
+ brokerPhoneIsValid: false,
440
+ });
441
+
442
+ await tour.submit();
443
+
444
+ expect(requests).to.have.length(0);
445
+ });
446
+
360
447
  it("books a tour with no broker fields when broker intake is off, whatever the form holds", async () => {
361
448
  const requests = captureRequests();
362
449
  const tour = createTour({
@@ -66,7 +66,11 @@ export interface StudioWebchatContext {
66
66
 
67
67
  export interface StudioWebchatClearContext {
68
68
  source?: "elise_studio";
69
- reason?: "no_property_for_route" | "webchat_disabled" | "ambiguous_route" | string;
69
+ reason?:
70
+ | "no_property_for_route"
71
+ | "webchat_disabled"
72
+ | "ambiguous_route"
73
+ | string;
70
74
  preserveCache?: boolean;
71
75
  }
72
76
 
@@ -209,6 +213,10 @@ export default class MEChat {
209
213
  // Centralized webchat is org-anchored: config comes from the org, not a
210
214
  // per-building slug or site resolution.
211
215
  meChatElement.setAttribute("centralConvo", "true");
216
+ if (opts.building) {
217
+ // Page context only: sent with visitor messages as declaredBuildingId.
218
+ meChatElement.setAttribute("declaredBuildingSlug", opts.building);
219
+ }
212
220
  } else {
213
221
  this.handleBuildingslug(
214
222
  meChatElement as MEChatLitElement,