@meetelise/chat 1.56.0 → 1.58.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.
@@ -206,6 +206,16 @@ export class TourScheduler extends LitElement {
206
206
  brokerEmail = "";
207
207
  @state()
208
208
  brokerFirmName = "";
209
+ @state()
210
+ brokerPhone = "";
211
+ @state()
212
+ brokerPhoneE164: string | null = null;
213
+ @state()
214
+ brokerPhoneCountry: string | null = null;
215
+ @state()
216
+ brokerPhoneIsValid = false;
217
+ @state()
218
+ brokerPhoneHasBlurred = false;
209
219
 
210
220
  @property({ attribute: false })
211
221
  leadSources: string[] = [];
@@ -669,25 +679,44 @@ export class TourScheduler extends LitElement {
669
679
  this.email = (e.target as HTMLInputElement).value;
670
680
  };
671
681
 
682
+ onChangeBrokerPhone = (
683
+ event: CustomEvent<InternationalPhoneChangeDetail>
684
+ ): void => {
685
+ this.brokerPhone = event.detail.formattedValue;
686
+ this.brokerPhoneE164 = event.detail.e164;
687
+ this.brokerPhoneCountry = event.detail.country;
688
+ this.brokerPhoneIsValid = event.detail.isValid;
689
+ };
690
+
672
691
  /**
673
692
  * A broker usually starts typing their own details before ticking the box, so ticking it moves
674
- * the name and email into the broker's empty details and leaves the client's fields empty. Once
675
- * the broker's details are filled, re-ticking leaves both sections alone, as on the email form.
693
+ * the name, email and phone into the broker's empty details and leaves the client's fields empty.
694
+ * Once the broker's details are filled, re-ticking leaves both sections alone, as on the email form.
676
695
  */
677
696
  onToggleBrokerReferral = (e: Event): void => {
678
697
  const isBrokerReferral = (e.target as HTMLInputElement).checked;
679
698
  const brokerIsEmpty =
680
- !this.brokerFirstName && !this.brokerLastName && !this.brokerEmail;
699
+ !this.brokerFirstName &&
700
+ !this.brokerLastName &&
701
+ !this.brokerEmail &&
702
+ !this.brokerPhone;
681
703
  const clientIsEmpty =
682
- !this.firstNameInputValue && !this.lastNameInputValue && !this.email;
704
+ !this.firstNameInputValue &&
705
+ !this.lastNameInputValue &&
706
+ !this.email &&
707
+ !this.phoneNumber;
683
708
  if (isBrokerReferral && brokerIsEmpty) {
684
709
  this.brokerFirstName = this.firstNameInputValue;
685
710
  this.brokerLastName = this.lastNameInputValue;
686
711
  this.brokerEmail = this.email;
712
+ this.brokerPhone = this.phoneNumber;
713
+ this.brokerPhoneE164 = this.phoneNumberE164;
714
+ this.brokerPhoneCountry = this.phoneCountry;
715
+ this.brokerPhoneIsValid = this.phoneNumberIsValid;
716
+ this.brokerPhoneHasBlurred = this.phoneNumberHasBlurred;
687
717
  this.firstNameInputValue = "";
688
718
  this.lastNameInputValue = "";
689
719
  this.email = "";
690
- // There's no broker phone field: what was typed is the broker's, not the client's.
691
720
  this.phoneNumber = "";
692
721
  this.phoneNumberE164 = null;
693
722
  this.phoneNumberIsValid = false;
@@ -696,9 +725,18 @@ export class TourScheduler extends LitElement {
696
725
  this.firstNameInputValue = this.brokerFirstName;
697
726
  this.lastNameInputValue = this.brokerLastName;
698
727
  this.email = this.brokerEmail;
728
+ this.phoneNumber = this.brokerPhone;
729
+ this.phoneNumberE164 = this.brokerPhoneE164;
730
+ this.phoneCountry = this.brokerPhoneCountry;
731
+ this.phoneNumberIsValid = this.brokerPhoneIsValid;
732
+ this.phoneNumberHasBlurred = this.brokerPhoneHasBlurred;
699
733
  this.brokerFirstName = "";
700
734
  this.brokerLastName = "";
701
735
  this.brokerEmail = "";
736
+ this.brokerPhone = "";
737
+ this.brokerPhoneE164 = null;
738
+ this.brokerPhoneIsValid = false;
739
+ this.brokerPhoneHasBlurred = false;
702
740
  }
703
741
  this.isBrokerReferral = isBrokerReferral;
704
742
  };
@@ -710,7 +748,8 @@ export class TourScheduler extends LitElement {
710
748
  this.brokerFirstName,
711
749
  this.brokerLastName,
712
750
  this.brokerEmail,
713
- this.brokerFirmName
751
+ this.brokerFirmName,
752
+ this.brokerPhoneE164 ?? ""
714
753
  )
715
754
  : null;
716
755
 
@@ -729,9 +768,12 @@ export class TourScheduler extends LitElement {
729
768
  !this.bedroomPreferenceRequired || !!this.selectedLayoutValue,
730
769
  broker: (): boolean => {
731
770
  const broker = this.brokerReferral();
771
+ // The broker's phone is optional, but one that's been typed has to be valid.
732
772
  return (
733
773
  !broker ||
734
- (!brokerNameIsMissing(broker) && !brokerEmailError(broker, this.email))
774
+ (!brokerNameIsMissing(broker) &&
775
+ !brokerEmailError(broker, this.email) &&
776
+ (!this.brokerPhone || this.brokerPhoneIsValid))
735
777
  );
736
778
  },
737
779
  };
@@ -1318,7 +1360,7 @@ export class TourScheduler extends LitElement {
1318
1360
 
1319
1361
  renderBrokerToggle = (): TemplateResult => html`
1320
1362
  <label class="broker-toggle">
1321
- <span>Are you an agent or locator?</span>
1363
+ <span>Are you a broker or locator?</span>
1322
1364
  <input
1323
1365
  type="checkbox"
1324
1366
  role="switch"
@@ -1392,6 +1434,25 @@ export class TourScheduler extends LitElement {
1392
1434
  />
1393
1435
  ${emailError ? html`<p class="error-message">${emailError}</p>` : ""}
1394
1436
  </div>
1437
+ <div class="inputContainer">
1438
+ <international-phone-input
1439
+ placeholder="Your phone (optional)"
1440
+ .country=${this.brokerPhoneCountry ?? this.country}
1441
+ .value=${this.brokerPhone}
1442
+ .invalid=${this.brokerPhoneHasBlurred &&
1443
+ !!this.brokerPhone &&
1444
+ !this.brokerPhoneIsValid}
1445
+ @phone-change=${this.onChangeBrokerPhone}
1446
+ @phone-blur=${() => {
1447
+ this.brokerPhoneHasBlurred = true;
1448
+ }}
1449
+ ></international-phone-input>
1450
+ ${this.brokerPhoneHasBlurred &&
1451
+ !!this.brokerPhone &&
1452
+ !this.brokerPhoneIsValid
1453
+ ? html`<p class="error-message">Invalid phone number</p>`
1454
+ : ""}
1455
+ </div>
1395
1456
  <div class="inputContainer">
1396
1457
  <input
1397
1458
  class=${inputClasses()}
@@ -1405,6 +1466,7 @@ export class TourScheduler extends LitElement {
1405
1466
  }}
1406
1467
  />
1407
1468
  </div>
1469
+ ${this.renderBrokerToggle()}
1408
1470
  <div class="section-label">Your client</div>
1409
1471
  `;
1410
1472
  }
@@ -1488,7 +1550,9 @@ export class TourScheduler extends LitElement {
1488
1550
  ? html`<p class="error-message">Invalid phone number</p>`
1489
1551
  : ""}
1490
1552
  </div>
1491
- ${this.brokerIntakeEnabled ? this.renderBrokerToggle() : ""}
1553
+ ${this.brokerIntakeEnabled && !isReferral
1554
+ ? this.renderBrokerToggle()
1555
+ : ""}
1492
1556
  ${this.layoutOptions.length > 0
1493
1557
  ? html` <me-select
1494
1558
  id="layout"
@@ -243,6 +243,16 @@ export class EmailUsWindow extends LitElement {
243
243
  brokerEmail = "";
244
244
  @state()
245
245
  brokerFirmName = "";
246
+ @state()
247
+ brokerPhone = "";
248
+ @state()
249
+ brokerPhoneE164: string | null = null;
250
+ @state()
251
+ brokerPhoneCountry: string | null = null;
252
+ @state()
253
+ brokerPhoneIsValid = false;
254
+ @state()
255
+ hasBrokerPhoneError = false;
246
256
 
247
257
  @query("me-select#leadSource")
248
258
  selectedLeadSource!: MESelect;
@@ -303,6 +313,15 @@ export class EmailUsWindow extends LitElement {
303
313
  this.phoneCountry = e.detail.country;
304
314
  };
305
315
 
316
+ onChangeBrokerPhone = (
317
+ e: CustomEvent<InternationalPhoneChangeDetail>
318
+ ): void => {
319
+ this.brokerPhone = e.detail.formattedValue;
320
+ this.brokerPhoneE164 = e.detail.e164;
321
+ this.brokerPhoneCountry = e.detail.country;
322
+ this.brokerPhoneIsValid = e.detail.isValid;
323
+ };
324
+
306
325
  onChangeMessage = (e: Event): void => {
307
326
  if (!e.target) {
308
327
  return;
@@ -313,36 +332,49 @@ export class EmailUsWindow extends LitElement {
313
332
 
314
333
  /**
315
334
  * A broker usually starts typing their own details before ticking the box, so ticking it moves
316
- * the name and email into the broker's empty details and leaves the client's fields empty. Once
335
+ * the name, email and phone into the broker's empty details and leaves the client's fields empty. Once
317
336
  * the broker's details are filled, re-ticking leaves both sections alone: the main fields then
318
337
  * hold the client. Unticking with the client's fields still empty moves the details back.
319
338
  */
320
339
  onToggleBrokerReferral = (e: Event): void => {
321
340
  const isBrokerReferral = (e.target as HTMLInputElement).checked;
322
341
  const brokerIsEmpty =
323
- !this.brokerFirstName && !this.brokerLastName && !this.brokerEmail;
342
+ !this.brokerFirstName &&
343
+ !this.brokerLastName &&
344
+ !this.brokerEmail &&
345
+ !this.brokerPhone;
324
346
  if (isBrokerReferral && brokerIsEmpty) {
325
347
  this.brokerFirstName = this.firstName;
326
348
  this.brokerLastName = this.lastName;
327
349
  this.brokerEmail = this.email;
350
+ this.brokerPhone = this.phoneNumber;
351
+ this.brokerPhoneE164 = this.phoneNumberE164;
352
+ this.brokerPhoneCountry = this.phoneCountry;
353
+ this.brokerPhoneIsValid = !!this.phoneNumberE164;
328
354
  this.firstName = "";
329
355
  this.lastName = "";
330
356
  this.email = "";
331
- // There's no broker phone field: what was typed is the broker's, not the client's.
332
357
  this.phoneNumber = "";
333
358
  this.phoneNumberE164 = null;
334
359
  } else if (
335
360
  !isBrokerReferral &&
336
361
  !this.firstName &&
337
362
  !this.lastName &&
338
- !this.email
363
+ !this.email &&
364
+ !this.phoneNumber
339
365
  ) {
340
366
  this.firstName = this.brokerFirstName;
341
367
  this.lastName = this.brokerLastName;
342
368
  this.email = this.brokerEmail;
369
+ this.phoneNumber = this.brokerPhone;
370
+ this.phoneNumberE164 = this.brokerPhoneE164;
371
+ this.phoneCountry = this.brokerPhoneCountry;
343
372
  this.brokerFirstName = "";
344
373
  this.brokerLastName = "";
345
374
  this.brokerEmail = "";
375
+ this.brokerPhone = "";
376
+ this.brokerPhoneE164 = null;
377
+ this.brokerPhoneIsValid = false;
346
378
  }
347
379
  this.isBrokerReferral = isBrokerReferral;
348
380
  this.hasNameError = false;
@@ -350,6 +382,7 @@ export class EmailUsWindow extends LitElement {
350
382
  this.hasPhoneNumberError = false;
351
383
  this.hasBrokerNameError = false;
352
384
  this.brokerEmailError = null;
385
+ this.hasBrokerPhoneError = false;
353
386
  this.windowHeight = this.baseHeight() + 30 * this.getNumErrors();
354
387
  };
355
388
 
@@ -372,7 +405,7 @@ export class EmailUsWindow extends LitElement {
372
405
  baseHeight = (): number =>
373
406
  525 +
374
407
  // The broker's rows, less the 79px the compact message box gives back.
375
- (this.brokerIntakeEnabled && this.isBrokerReferral ? 170 - 79 : 0);
408
+ (this.brokerIntakeEnabled && this.isBrokerReferral ? 222 - 79 : 0);
376
409
 
377
410
  /** The broker's details to send, only when the submitter says they're referring a client. */
378
411
  brokerReferral = (): BrokerReferral | null =>
@@ -381,7 +414,8 @@ export class EmailUsWindow extends LitElement {
381
414
  this.brokerFirstName,
382
415
  this.brokerLastName,
383
416
  this.brokerEmail,
384
- this.brokerFirmName
417
+ this.brokerFirmName,
418
+ this.brokerPhoneE164 ?? ""
385
419
  )
386
420
  : null;
387
421
 
@@ -393,10 +427,13 @@ export class EmailUsWindow extends LitElement {
393
427
  this.hasSubmissionError = false;
394
428
  this.hasBrokerNameError = false;
395
429
  this.brokerEmailError = null;
430
+ this.hasBrokerPhoneError = false;
396
431
  const broker = this.brokerReferral();
397
432
  if (broker) {
398
433
  this.hasBrokerNameError = brokerNameIsMissing(broker);
399
434
  this.brokerEmailError = brokerEmailError(broker, this.email);
435
+ // The broker's phone is optional, but one that's been typed has to be valid.
436
+ this.hasBrokerPhoneError = !!this.brokerPhone && !this.brokerPhoneIsValid;
400
437
  }
401
438
  if (!this.firstName || !this.lastName) {
402
439
  this.hasNameError = true;
@@ -421,7 +458,8 @@ export class EmailUsWindow extends LitElement {
421
458
  this.hasPhoneNumberError ||
422
459
  this.hasMessageError ||
423
460
  this.hasBrokerNameError ||
424
- this.brokerEmailError
461
+ this.brokerEmailError ||
462
+ this.hasBrokerPhoneError
425
463
  ) {
426
464
  return;
427
465
  }
@@ -496,12 +534,13 @@ export class EmailUsWindow extends LitElement {
496
534
  this.hasSubmissionError,
497
535
  this.hasBrokerNameError,
498
536
  !!this.brokerEmailError,
537
+ this.hasBrokerPhoneError,
499
538
  ].filter((v) => v).length;
500
539
  };
501
540
 
502
541
  renderBrokerToggle = (): TemplateResult => html`
503
542
  <label class="email-us__broker-toggle">
504
- <span>Are you an agent or locator?</span>
543
+ <span>Are you a broker or locator?</span>
505
544
  <input
506
545
  type="checkbox"
507
546
  role="switch"
@@ -563,6 +602,19 @@ export class EmailUsWindow extends LitElement {
563
602
  </div>`
564
603
  : ""}
565
604
  <div class="email-us__vertical-spacer"></div>
605
+ <international-phone-input
606
+ placeholder="Your phone (optional)"
607
+ .country=${this.brokerPhoneCountry ?? this.country}
608
+ .value=${this.brokerPhone}
609
+ .invalid=${this.hasBrokerPhoneError}
610
+ @phone-change=${this.onChangeBrokerPhone}
611
+ ></international-phone-input>
612
+ ${this.hasBrokerPhoneError
613
+ ? html`<div class="email-us__error-text">
614
+ Enter a valid phone number
615
+ </div>`
616
+ : ""}
617
+ <div class="email-us__vertical-spacer"></div>
566
618
  <input
567
619
  type="text"
568
620
  class=${inputClasses("email-us__contact-input")}
@@ -572,6 +624,8 @@ export class EmailUsWindow extends LitElement {
572
624
  @input=${this.onChangeBrokerField("brokerFirmName")}
573
625
  />
574
626
  <div class="email-us__vertical-spacer"></div>
627
+ ${this.renderBrokerToggle()}
628
+ <div class="email-us__vertical-spacer"></div>
575
629
  <div class="email-us__section-label">Your client</div>
576
630
  `
577
631
  : ""}
@@ -706,7 +760,7 @@ export class EmailUsWindow extends LitElement {
706
760
  `
707
761
  : ""}
708
762
  <div class="email-us__vertical-spacer"></div>
709
- ${this.brokerIntakeEnabled
763
+ ${this.brokerIntakeEnabled && !this.isBrokerReferral
710
764
  ? html`${this.renderBrokerToggle()}
711
765
  <div class="email-us__vertical-spacer"></div>`
712
766
  : ""}
@@ -130,6 +130,9 @@ export class InternationalPhoneInput extends LitElement {
130
130
  @property({ type: Boolean })
131
131
  invalid = false;
132
132
 
133
+ @property({ type: String })
134
+ placeholder = "Phone";
135
+
133
136
  @state()
134
137
  private selectedCountry = "US";
135
138
 
@@ -287,7 +290,7 @@ export class InternationalPhoneInput extends LitElement {
287
290
  class="webchat-input"
288
291
  type="tel"
289
292
  inputmode="tel"
290
- placeholder="Phone"
293
+ placeholder=${this.placeholder}
291
294
  name="phone"
292
295
  autocomplete="tel-national"
293
296
  maxlength="30"
@@ -6,6 +6,8 @@ export type BrokerReferral = {
6
6
  lastName: string;
7
7
  email: string;
8
8
  firmName: string;
9
+ /** E.164, or "" when the form has no broker phone or it was left empty. */
10
+ phone: string;
9
11
  };
10
12
 
11
13
  /** The broker's typed details, trimmed, as a referral. */
@@ -13,12 +15,14 @@ export const toBrokerReferral = (
13
15
  firstName: string,
14
16
  lastName: string,
15
17
  email: string,
16
- firmName: string
18
+ firmName: string,
19
+ phone = ""
17
20
  ): BrokerReferral => ({
18
21
  firstName: firstName.trim(),
19
22
  lastName: lastName.trim(),
20
23
  email: email.trim(),
21
24
  firmName: firmName.trim(),
25
+ phone,
22
26
  });
23
27
 
24
28
  export const brokerNameIsMissing = (broker: BrokerReferral): boolean =>
@@ -51,5 +55,6 @@ export const brokerRequestFields = (
51
55
  broker_last_name: broker.lastName,
52
56
  broker_email: broker.email,
53
57
  broker_firm_name: broker.firmName || null,
58
+ ...(broker.phone ? { broker_phone: broker.phone } : {}),
54
59
  }
55
60
  : {};
@@ -144,6 +144,11 @@ export class MEChat extends LitElement {
144
144
  */
145
145
  @property({ type: String })
146
146
  private declaredBuildingSlug = "";
147
+ /**
148
+ * Org-level webchat on a host that knows the page's building by id (Elise
149
+ * Studio), set with `setDeclaredBuildingId`. Undefined until the host sets it.
150
+ */
151
+ private hostDeclaredBuildingId: number | null | undefined = undefined;
147
152
  /**
148
153
  * Org-level webchat (`centralConvo` started without a building). Stays true
149
154
  * after the widget adopts the org's triage general building from
@@ -700,10 +705,16 @@ export class MEChat extends LitElement {
700
705
  if (this.centralConvo) {
701
706
  const myPubnub = this.myPubnub;
702
707
  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
- });
708
+ if (this.hostDeclaredBuildingId !== undefined) {
709
+ myPubnub.declaredBuildingId = this.hostDeclaredBuildingId;
710
+ } else {
711
+ // Not awaited: only needed once the visitor sends a message.
712
+ void this.fetchDeclaredBuildingId().then((id) => {
713
+ if (this.hostDeclaredBuildingId === undefined) {
714
+ myPubnub.declaredBuildingId = id;
715
+ }
716
+ });
717
+ }
707
718
  } else {
708
719
  this.myPubnub.declaredBuildingId = this.buildingWebchatView.id;
709
720
  }
@@ -814,6 +825,16 @@ export class MEChat extends LitElement {
814
825
  };
815
826
  };
816
827
 
828
+ /**
829
+ * Org-level webchat: the page's building, sent with later visitor messages as
830
+ * `declaredBuildingId`. For hosts that move between pages without restarting
831
+ * the widget; it doesn't change config or the conversation.
832
+ */
833
+ setDeclaredBuildingId(id: number | null): void {
834
+ this.hostDeclaredBuildingId = id;
835
+ if (this.myPubnub) this.myPubnub.declaredBuildingId = id;
836
+ }
837
+
817
838
  /** The numeric id of the snippet's building in org-level webchat, if it names one. */
818
839
  private fetchDeclaredBuildingId = async (): Promise<number | null> => {
819
840
  if (!this.declaredBuildingSlug || !this.orgSlug) return null;
@@ -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({
@@ -40,7 +40,8 @@ export type StudioWebchatMode = "host_managed_context";
40
40
  export type StudioWebchatSessionScopeType =
41
41
  | "building"
42
42
  | "building_group"
43
- | "site";
43
+ | "site"
44
+ | "org";
44
45
 
45
46
  export interface StudioWebchatSessionScope {
46
47
  type: StudioWebchatSessionScopeType;
@@ -60,6 +61,11 @@ export interface StudioWebchatContext {
60
61
  building?: string | null;
61
62
  buildingGroupId?: string | number | null;
62
63
  sessionScope?: StudioWebchatSessionScope;
64
+ /**
65
+ * Org scope only: the page's building, sent with visitor messages as
66
+ * `declaredBuildingId`. Null on pages with no single building.
67
+ */
68
+ declaredBuildingId?: number | null;
63
69
  path?: string;
64
70
  pageId?: string;
65
71
  }
@@ -323,6 +329,11 @@ export default class MEChat {
323
329
  return;
324
330
  }
325
331
 
332
+ if (context.sessionScope?.type === "org") {
333
+ this.updateStudioOrgContext(context, opts, updateToken);
334
+ return;
335
+ }
336
+
326
337
  const orgSlug = coerceStudioContextString(context.organization);
327
338
  const buildingSlug = coerceStudioContextString(context.building);
328
339
 
@@ -368,6 +379,64 @@ export default class MEChat {
368
379
  });
369
380
  }
370
381
 
382
+ /**
383
+ * Org-level webchat on a Studio site: one org-anchored widget for the whole
384
+ * site, started as the org-level pixel starts it. Route changes only move the
385
+ * page's declared building, so the conversation carries across pages.
386
+ */
387
+ static updateStudioOrgContext(
388
+ context: StudioWebchatContext,
389
+ opts: Options,
390
+ updateToken: number
391
+ ): void {
392
+ const orgSlug = coerceStudioContextString(context.organization);
393
+ if (!orgSlug) {
394
+ this.clearStudioContext({
395
+ source: "elise_studio",
396
+ reason: "missing_context",
397
+ preserveCache: true,
398
+ });
399
+ return;
400
+ }
401
+
402
+ const normalizedOrgSlug = normalizeOrgSlug(orgSlug);
403
+ const sessionScopeKey = context.sessionScope?.key || `org:${orgSlug}`;
404
+ const declaredBuildingId = coerceDeclaredBuildingId(
405
+ context.declaredBuildingId
406
+ );
407
+
408
+ if (
409
+ this.meChat &&
410
+ this.activeStudioOrgSlug === normalizedOrgSlug &&
411
+ !this.activeStudioBuildingSlug &&
412
+ this.activeStudioSessionScopeKey === sessionScopeKey
413
+ ) {
414
+ this.meChat.setDeclaredBuildingId(declaredBuildingId);
415
+ return;
416
+ }
417
+
418
+ void this.removeWidget().then(() => {
419
+ if (updateToken !== this.studioUpdateToken) return;
420
+
421
+ this.activeStudioOrgSlug = normalizedOrgSlug;
422
+ this.activeStudioBuildingSlug = "";
423
+ this.activeStudioSessionScopeKey = sessionScopeKey;
424
+ this.orgSlug = normalizedOrgSlug;
425
+
426
+ this.start(
427
+ {
428
+ ...opts,
429
+ studio: undefined,
430
+ organization: orgSlug,
431
+ building: undefined,
432
+ centralConvo: true,
433
+ },
434
+ false
435
+ );
436
+ this.meChat?.setDeclaredBuildingId(declaredBuildingId);
437
+ });
438
+ }
439
+
371
440
  static clearStudioContext(_context?: StudioWebchatClearContext): void {
372
441
  void _context;
373
442
  this.studioUpdateToken += 1;
@@ -536,6 +605,11 @@ function normalizeOrgSlug(orgSlug: string): string {
536
605
  : orgSlug;
537
606
  }
538
607
 
608
+ function coerceDeclaredBuildingId(value: unknown): number | null {
609
+ const id = typeof value === "string" ? Number(value) : value;
610
+ return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : null;
611
+ }
612
+
539
613
  function coerceStudioSessionScopeKey(
540
614
  sessionScope: StudioWebchatSessionScope | undefined,
541
615
  buildingSlug: string