@meetelise/chat 1.44.0 → 1.46.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.
@@ -57,11 +57,18 @@ import { TourVirtuallyIcon } from "../icons/TourVirtuallyIcon";
57
57
  import { TourSelfGuidedIcon } from "../icons/TourSelfGuidedIcon";
58
58
  import { TourWithAgentIcon } from "../icons/TourWithAgentIcon";
59
59
  import { shouldOpenTourLink } from "../../fetchBuildingWebchatView";
60
+ import fetchBuildingWebchatView from "../../fetchBuildingWebchatView";
61
+ import fetchCentralConvoBuildings, {
62
+ CentralConvoBuilding,
63
+ } from "../../fetchCentralConvoBuildings";
60
64
  import formatInTimeZone from "date-fns-tz/formatInTimeZone";
61
65
  import { getTimezoneAbbreviation } from "../../getTimezoneString";
62
66
  import startOfDay from "date-fns/startOfDay";
63
67
  import isSameDay from "date-fns/isSameDay";
64
68
  import { LeadSourceMultitouchClient } from "../LeadSourceMultitouchClient";
69
+ import fetchBuildingUnitsSummary, {
70
+ UnitSummary,
71
+ } from "../../fetchBuildingUnitsSummary";
65
72
 
66
73
  @customElement("tour-scheduler")
67
74
  export class TourScheduler extends LitElement {
@@ -196,6 +203,8 @@ export class TourScheduler extends LitElement {
196
203
  selectedLeadSource!: MESelect;
197
204
  @query("me-select#layout")
198
205
  selectedLayoutEl?: MESelect;
206
+ @query("me-select#unit")
207
+ selectedUnitEl?: MESelect;
199
208
 
200
209
  @state()
201
210
  firstNameInputValue = "";
@@ -205,10 +214,23 @@ export class TourScheduler extends LitElement {
205
214
  leadSourceInputValue = "";
206
215
  @state()
207
216
  selectedLayoutValue = "";
217
+ @state()
218
+ selectedUnitValue = "";
219
+ @state()
220
+ private units: UnitSummary[] = [];
221
+ @state()
222
+ private allowOccupiedUnitTours = false;
208
223
 
209
224
  @state()
210
225
  errorGettingAvailabilities = false;
211
226
 
227
+ @state()
228
+ private centralConvoBuildings: CentralConvoBuilding[] = [];
229
+ @query("me-select#building")
230
+ selectedBuildingEl?: MESelect;
231
+ @state()
232
+ private statusMessage = "";
233
+
212
234
  _setAvailabilities = async (): Promise<void> => {
213
235
  try {
214
236
  const [allowScheduling, availabilitiesExistForTourType] =
@@ -410,10 +432,151 @@ export class TourScheduler extends LitElement {
410
432
  return null;
411
433
  };
412
434
 
435
+ _getUnitOptions = (): { label: string; value: string }[] => {
436
+ const visible = this.selectedLayoutValue
437
+ ? this.units.filter(
438
+ (u) =>
439
+ layoutValueToCanonical(u.numberOfBedrooms * 10) ===
440
+ this.selectedLayoutValue
441
+ )
442
+ : this.units;
443
+ const unitOptions = visible.map((u) => ({
444
+ label: u.unitNumber,
445
+ value: u.unitNumber,
446
+ }));
447
+ return unitOptions;
448
+ };
449
+
450
+ _fetchUnits = async (): Promise<void> => {
451
+ if (!this.buildingSlug) return;
452
+ try {
453
+ this.units = await fetchBuildingUnitsSummary({
454
+ buildingSlug: this.buildingSlug,
455
+ });
456
+ } catch (e) {
457
+ this.units = [];
458
+ sendLoggingEvent({
459
+ logTitle: "ERROR_LOADING_UNITS_FOR_TOUR_SCHEDULER",
460
+ logData: { error: e },
461
+ logType: LogType.error,
462
+ buildingSlug: this.buildingSlug,
463
+ orgSlug: this.orgSlug,
464
+ });
465
+ }
466
+ };
467
+
413
468
  firstUpdated = async (): Promise<void> => {
414
- await this._setAvailabilities();
469
+ this._fetchCentralConvoBuildings();
470
+ await this._loadForCurrentBuilding();
471
+ };
472
+
473
+ private _loadForCurrentBuilding = async (): Promise<void> => {
474
+ try {
475
+ await getRawAvailabilities(this.buildingId);
476
+ } catch (e) {
477
+ // noop
478
+ }
479
+ await Promise.all([
480
+ this._setAvailabilities().then(async () => {
481
+ try {
482
+ const rawAvailabilities = await getRawAvailabilities(this.buildingId);
483
+ this.allowOccupiedUnitTours =
484
+ !!rawAvailabilities.allowOccupiedUnitTours;
485
+ } catch (e) {
486
+ this.allowOccupiedUnitTours = false;
487
+ }
488
+ }),
489
+ this._fetchUnits(),
490
+ ]);
491
+ };
492
+
493
+ private _fetchCentralConvoBuildings = async (): Promise<void> => {
494
+ if (!this.orgSlug || !this.buildingSlug) return;
495
+ try {
496
+ this.centralConvoBuildings = await fetchCentralConvoBuildings(
497
+ this.orgSlug,
498
+ this.buildingSlug
499
+ );
500
+ } catch (e) {
501
+ this.centralConvoBuildings = [];
502
+ }
415
503
  };
416
504
 
505
+ private _onBuildingChange = async (): Promise<void> => {
506
+ const slug = this.selectedBuildingEl?.value;
507
+ if (!slug || slug === this.buildingSlug) return;
508
+ const building = this.centralConvoBuildings.find((b) => b.slug === slug);
509
+ if (!building) return;
510
+
511
+ this.waitingForAvailabilities = true;
512
+ this.errorGettingAvailabilities = false;
513
+ this.tourType = null;
514
+ this.selectedDate = undefined;
515
+ this.selectedTime = undefined;
516
+ this.availabilitiesGroupedByDay = {};
517
+ this.mobilePageIndex = 0;
518
+ this.units = [];
519
+ this.selectedLayoutValue = "";
520
+ this.selectedUnitValue = "";
521
+
522
+ this.buildingId = building.building_id;
523
+ this.buildingSlug = building.slug;
524
+ this.buildingName = building.name;
525
+ this.statusMessage = `Loading tours for ${building.name}…`;
526
+
527
+ pushGtmEvent("tourBuildingSelected", {
528
+ buildingId: building.building_id,
529
+ });
530
+
531
+ try {
532
+ const view = await fetchBuildingWebchatView(this.orgSlug, building.slug);
533
+ this.sgtUrl = view.sgtUrl;
534
+ this.selfGuidedToursTypeOffered = view.selfGuidedToursTypeOffered;
535
+ this.selfGuidedTourEnabled = view.isSelfGuidedTourEnabled;
536
+ this.escortedToursLink = view.escortedToursLink;
537
+ this.escortedToursTypeOffered = view.escortedToursTypeOffered;
538
+ this.virtualToursLink = view.virtualToursLink;
539
+ this.virtualToursTypeOffered = view.virtualToursTypeOffered;
540
+ this.tourTypeOptions = view.tourTypeOptions.map((o) => ({
541
+ label: o.label,
542
+ value: o.value,
543
+ }));
544
+ this.hasDynamicSchedulingEnabled = view.usesDynamicScheduling;
545
+ } catch (e) {
546
+ // eslint-disable-next-line no-console
547
+ console.error("Failed to load building config for selection", e);
548
+ }
549
+
550
+ await this._loadForCurrentBuilding();
551
+
552
+ if (!this.shouldAllowScheduling || this.errorGettingAvailabilities) {
553
+ this.statusMessage = `No tour availabilities for ${building.name}.`;
554
+ } else {
555
+ const count = Object.values(this.shouldShowTourType).filter(
556
+ Boolean
557
+ ).length;
558
+ this.statusMessage = `Showing tours for ${
559
+ building.name
560
+ }. ${count} tour type${count === 1 ? "" : "s"} available.`;
561
+ }
562
+ };
563
+
564
+ buildingSelector(): TemplateResult | string {
565
+ if (this.centralConvoBuildings.length <= 1) return "";
566
+ return html`<h2 class="journey-header">Building</h2>
567
+ <me-select
568
+ id="building"
569
+ placeholder="Select a property"
570
+ .value=${this.buildingSlug}
571
+ .options="${this.centralConvoBuildings.map((b) => ({
572
+ label: b.name,
573
+ value: b.slug,
574
+ }))}"
575
+ @change=${this._onBuildingChange}
576
+ >
577
+ </me-select>`;
578
+ }
579
+
417
580
  protected willUpdate = async (
418
581
  _changedProperties:
419
582
  | PropertyValueMap<{ tourType: TourType }>
@@ -652,6 +815,8 @@ export class TourScheduler extends LitElement {
652
815
  this.leadSourceInputValue = parsedLeadSource;
653
816
  const selectedLayout =
654
817
  this.selectedLayoutEl?.value || this.selectedLayoutValue || null;
818
+ const selectedUnit =
819
+ this.selectedUnitEl?.value || this.selectedUnitValue || null;
655
820
  pushGtmEvent("scheduleTourSubmitted", {
656
821
  email: this.email,
657
822
  phone: `+1${this.phoneNumber.match(/\d/g)?.join("")}`,
@@ -662,6 +827,7 @@ export class TourScheduler extends LitElement {
662
827
  originatingSource:
663
828
  leadSources.find((i) => i !== "property-website") || null,
664
829
  layout: selectedLayout,
830
+ unit: selectedUnit,
665
831
  });
666
832
  const data = {
667
833
  referrer: document.referrer,
@@ -683,6 +849,7 @@ export class TourScheduler extends LitElement {
683
849
  query_params: Object.fromEntries(queryParams.entries()),
684
850
  conversation_tracking_id: this.leadSourceClient?.chatId,
685
851
  layouts: selectedLayout ? [selectedLayout] : undefined,
852
+ unit_numbers: selectedUnit ? [selectedUnit] : undefined,
686
853
  lead_sources_with_timestamps: (
687
854
  this.leadSourceMultitouchClient?.getSafeLeadSourceTouchpointsWithDefault(
688
855
  {
@@ -773,7 +940,8 @@ export class TourScheduler extends LitElement {
773
940
  static styles = [tourSchedulerStyles, InputStyles];
774
941
 
775
942
  tourTypeMenu(): TemplateResult {
776
- return html`<h2 id="tour-type-heading" class="journey-header">Tour Type</h2>
943
+ return html`${this.buildingSelector()}
944
+ <h2 id="tour-type-heading" class="journey-header">Tour Type</h2>
777
945
  <div
778
946
  id="tour-type-menu"
779
947
  role="radiogroup"
@@ -1247,6 +1415,21 @@ export class TourScheduler extends LitElement {
1247
1415
  @change=${() => {
1248
1416
  const v = this.selectedLayoutEl?.value || "";
1249
1417
  this.selectedLayoutValue = v;
1418
+ // Clear unit selection if it no longer matches the new layout.
1419
+ if (v && this.selectedUnitValue) {
1420
+ const unit = this.units.find(
1421
+ (u) => u.unitNumber === this.selectedUnitValue
1422
+ );
1423
+ if (
1424
+ unit &&
1425
+ layoutValueToCanonical(unit.numberOfBedrooms * 10) !== v
1426
+ ) {
1427
+ this.selectedUnitValue = "";
1428
+ if (this.selectedUnitEl) {
1429
+ this.selectedUnitEl.value = undefined;
1430
+ }
1431
+ }
1432
+ }
1250
1433
  if (v) {
1251
1434
  pushGtmEvent("tourLayoutSelected", {
1252
1435
  layout: v,
@@ -1257,6 +1440,25 @@ export class TourScheduler extends LitElement {
1257
1440
  >
1258
1441
  </me-select>`
1259
1442
  : ""}
1443
+ ${this.allowOccupiedUnitTours && this._getUnitOptions().length > 0
1444
+ ? html` <me-select
1445
+ id="unit"
1446
+ placeholder="Unit preference (optional)"
1447
+ .value=${this.selectedUnitValue}
1448
+ .options="${this._getUnitOptions()}"
1449
+ @change=${() => {
1450
+ const v = this.selectedUnitEl?.value || "";
1451
+ this.selectedUnitValue = v;
1452
+ if (v) {
1453
+ pushGtmEvent("tourUnitSelected", {
1454
+ unit: v,
1455
+ buildingId: this.buildingId,
1456
+ });
1457
+ }
1458
+ }}
1459
+ >
1460
+ </me-select>`
1461
+ : ""}
1260
1462
  ${this.leadSources.length > 0 &&
1261
1463
  (this.featureFlagShowDropdown === FeatureFlagsShowDropdown.always ||
1262
1464
  (this.featureFlagShowDropdown ===
@@ -1368,6 +1570,15 @@ export class TourScheduler extends LitElement {
1368
1570
  }
1369
1571
 
1370
1572
  render(): TemplateResult {
1573
+ return html`
1574
+ <div class="sr-only" role="status" aria-live="polite">
1575
+ ${this.statusMessage}
1576
+ </div>
1577
+ ${this.renderBody()}
1578
+ `;
1579
+ }
1580
+
1581
+ private renderBody(): TemplateResult {
1371
1582
  const isLoading =
1372
1583
  this.waitingForAvailabilities || this.shouldAllowScheduleLoading;
1373
1584
  if (!this.shouldAllowScheduling && !isLoading) {
@@ -111,6 +111,17 @@ export const tourSchedulerStyles = css`
111
111
  font-size: 14px;
112
112
  font-weight: 600;
113
113
  }
114
+ .sr-only {
115
+ position: absolute;
116
+ width: 1px;
117
+ height: 1px;
118
+ padding: 0;
119
+ margin: -1px;
120
+ overflow: hidden;
121
+ clip: rect(0, 0, 0, 0);
122
+ white-space: nowrap;
123
+ border: 0;
124
+ }
114
125
  #close-button {
115
126
  background: none;
116
127
  border: none;
@@ -138,6 +149,11 @@ export const tourSchedulerStyles = css`
138
149
  padding-bottom: 12px;
139
150
  }
140
151
 
152
+ me-select#building {
153
+ display: block;
154
+ margin-bottom: 20px;
155
+ }
156
+
141
157
  #tour-type-menu-outer-container {
142
158
  width: 220px;
143
159
  }
@@ -0,0 +1,37 @@
1
+ import axios from "axios";
2
+ import { LogType, sendLoggingEvent } from "./analytics";
3
+ import { BASE_DOMAIN } from "./globals";
4
+ import { camelize } from "./utils";
5
+
6
+ export type UnitSummary = {
7
+ id: string;
8
+ unitNumber: string;
9
+ numberOfBedrooms: number;
10
+ };
11
+
12
+ type FetchBuildingUnitsSummaryParams = {
13
+ buildingSlug: string;
14
+ };
15
+
16
+ const fetchBuildingUnitsSummary = async ({
17
+ buildingSlug,
18
+ }: FetchBuildingUnitsSummaryParams): Promise<UnitSummary[]> => {
19
+ try {
20
+ const response = await axios.get(
21
+ `${BASE_DOMAIN}/platformApi/webchat/${buildingSlug}/units/summary`
22
+ );
23
+ if (response.data) {
24
+ return camelize<UnitSummary[]>(response.data);
25
+ }
26
+ } catch (error) {
27
+ sendLoggingEvent({
28
+ logType: LogType.error,
29
+ buildingSlug,
30
+ logTitle: "[ERROR_GETTING_UNITS_SUMMARY]",
31
+ logData: { error },
32
+ });
33
+ }
34
+ return [];
35
+ };
36
+
37
+ export default fetchBuildingUnitsSummary;
@@ -0,0 +1,32 @@
1
+ import axios from "axios";
2
+
3
+ export interface CentralConvoBuilding {
4
+ building_id: number;
5
+ name: string;
6
+ slug: string;
7
+ }
8
+
9
+ /**
10
+ * Fetch the buildings that belong to the same central conversation group as the
11
+ * current building. Returns an empty array when the building is not part of a
12
+ * central conversation group (in which case no building selector is shown).
13
+ *
14
+ * @param orgSlug - The org slug, e.g. "big-prop-co"
15
+ * @param buildingSlug - The current building's slug, e.g. "gravity-falls"
16
+ */
17
+ export default async function fetchCentralConvoBuildings(
18
+ orgSlug: string,
19
+ buildingSlug: string
20
+ ): Promise<CentralConvoBuilding[]> {
21
+ const host = "https://app.meetelise.com";
22
+ const url = `${host}/platformApi/webchat/central-convo-buildings`;
23
+
24
+ const response = await axios.get<CentralConvoBuilding[]>(url, {
25
+ headers: {
26
+ ["building-slug"]: buildingSlug,
27
+ ["org-slug"]: orgSlug,
28
+ },
29
+ });
30
+
31
+ return response.data ?? [];
32
+ }
@@ -10,4 +10,8 @@ export enum TourAvailabilityResponseRankOrderedSupportedTourTypesEnum {
10
10
  }
11
11
 
12
12
  // Types are safe to re-export as they are not included in runtime code.
13
- export type { TourAvailabilityResponse } from "@meetelise/rest-sdk";
13
+ import type { TourAvailabilityResponse as SdkTourAvailabilityResponse } from "@meetelise/rest-sdk";
14
+
15
+ export type TourAvailabilityResponse = SdkTourAvailabilityResponse & {
16
+ allowOccupiedUnitTours?: boolean;
17
+ };