@meetelise/chat 1.51.12 → 1.51.14

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.
@@ -424,7 +424,12 @@ export class MEChat extends LitElement {
424
424
  this.enabledChatWidgets.sstMobile ||
425
425
  this.orgSlug === "radco"
426
426
  ) {
427
- replaceSSTLinkWithTourWidgetTrigger(this.handleTourClicked);
427
+ replaceSSTLinkWithTourWidgetTrigger(
428
+ this.handleTourClicked,
429
+ this.orgSlug,
430
+ this.buildingSlug,
431
+ () => this.isConnected
432
+ );
428
433
  if (featureFlagReplaceScheduleTourCtaWebsite) {
429
434
  replacePageRedirectElementWithTourWidgetTrigger(this.handleTourClicked);
430
435
  }
@@ -0,0 +1,7 @@
1
+ export enum LogType {
2
+ error = "error",
3
+ info = "info",
4
+ warn = "warn",
5
+ }
6
+
7
+ export const sendLoggingEvent = (): Promise<void> => Promise.resolve();
@@ -0,0 +1,217 @@
1
+ import { expect } from "@esm-bundle/chai";
2
+ import { insertDNIIntoWebsite } from "./insertDNIIntoWebsite";
3
+
4
+ const DNI_NUMBER = "3035550123";
5
+ const DNI_DISPLAY = "(303) 555-0123";
6
+
7
+ const render = (html: string): void => {
8
+ document.body.innerHTML = html;
9
+ };
10
+
11
+ const insert = (cleanPhoneNumber: string = DNI_NUMBER): number =>
12
+ insertDNIIntoWebsite(cleanPhoneNumber, "test-org", "test-building");
13
+
14
+ const textOf = (selector: string): string | null =>
15
+ document.querySelector(selector)?.textContent ?? null;
16
+
17
+ const hrefOf = (selector: string): string | null =>
18
+ document.querySelector(selector)?.getAttribute("href") ?? null;
19
+
20
+ describe("insertDNIIntoWebsite", () => {
21
+ afterEach(() => {
22
+ document.body.innerHTML = "";
23
+ });
24
+
25
+ describe("display text", () => {
26
+ it("replaces common US phone formats", () => {
27
+ const formats = [
28
+ "(855) 383-2298",
29
+ "855-383-2298",
30
+ "855.383.2298",
31
+ "855 383 2298",
32
+ "(855)383-2298",
33
+ "855-3832298",
34
+ ];
35
+ render(formats.map((f, i) => `<span id="n${i}">${f}</span>`).join(""));
36
+
37
+ insert();
38
+
39
+ formats.forEach((format, i) => {
40
+ expect(textOf(`#n${i}`)).to.equal(DNI_DISPLAY, format);
41
+ });
42
+ });
43
+
44
+ it("keeps a +1 country code prefix", () => {
45
+ render(`<span id="n">+1 855-383-2298</span>`);
46
+
47
+ insert();
48
+
49
+ expect(textOf("#n")).to.equal(`+1 ${DNI_DISPLAY}`);
50
+ });
51
+
52
+ it("replaces a number embedded in surrounding text", () => {
53
+ render(`<p id="n">Call us at 855-383-2298 today!</p>`);
54
+
55
+ insert();
56
+
57
+ expect(textOf("#n")).to.equal(`Call us at ${DNI_DISPLAY} today!`);
58
+ });
59
+
60
+ it("accepts 10-digit, 11-digit, and +1 DNI numbers", () => {
61
+ for (const dni of ["3035550123", "13035550123", "+13035550123"]) {
62
+ render(`<span id="n">855-383-2298</span>`);
63
+
64
+ insert(dni);
65
+
66
+ expect(textOf("#n")).to.equal(DNI_DISPLAY, dni);
67
+ }
68
+ });
69
+
70
+ it("replaces both the text and tel: href of a phone link", () => {
71
+ render(`<a id="n" href="tel:8553832298">(855) 383-2298</a>`);
72
+
73
+ insert();
74
+
75
+ expect(textOf("#n")).to.equal(DNI_DISPLAY);
76
+ expect(hrefOf("#n")).to.equal(`tel:${DNI_NUMBER}`);
77
+ });
78
+ });
79
+
80
+ describe("NANP validity", () => {
81
+ it("ignores numbers whose area code starts with 0 or 1", () => {
82
+ render(
83
+ `<span id="a">123-456-7890</span><span id="b">(023) 456-7890</span>`
84
+ );
85
+
86
+ insert();
87
+
88
+ expect(textOf("#a")).to.equal("123-456-7890");
89
+ expect(textOf("#b")).to.equal("(023) 456-7890");
90
+ });
91
+
92
+ it("ignores numbers whose exchange starts with 0 or 1", () => {
93
+ render(
94
+ `<span id="a">(212) 155-1234</span><span id="b">212-055-1234</span>`
95
+ );
96
+
97
+ insert();
98
+
99
+ expect(textOf("#a")).to.equal("(212) 155-1234");
100
+ expect(textOf("#b")).to.equal("212-055-1234");
101
+ });
102
+
103
+ it("ignores western US longitudes in visible text", () => {
104
+ render(`<p id="n">Lat/Long: 39.9038078, -104.9932859</p>`);
105
+
106
+ insert();
107
+
108
+ expect(textOf("#n")).to.equal("Lat/Long: 39.9038078, -104.9932859");
109
+ });
110
+
111
+ it("keeps JSON-LD geo coordinates valid", () => {
112
+ render(
113
+ `<script id="n" type="application/ld+json">${JSON.stringify({
114
+ "@type": "ApartmentComplex",
115
+ telephone: "855-383-2298",
116
+ geo: {
117
+ "@type": "GeoCoordinates",
118
+ latitude: 39.9038078,
119
+ longitude: -104.9932859,
120
+ },
121
+ })}</script>`
122
+ );
123
+
124
+ insert();
125
+
126
+ const schema = JSON.parse(textOf("#n") ?? "");
127
+ expect(schema.geo.latitude).to.equal(39.9038078);
128
+ expect(schema.geo.longitude).to.equal(-104.9932859);
129
+ expect(schema.telephone).to.equal(DNI_DISPLAY);
130
+ });
131
+ });
132
+
133
+ describe("guardrails", () => {
134
+ it("does not replace protected numbers", () => {
135
+ render(`<a id="n" href="tel:8002221222">800-222-1222</a>`);
136
+
137
+ insert();
138
+
139
+ expect(textOf("#n")).to.equal("800-222-1222");
140
+ expect(hrefOf("#n")).to.equal("tel:8002221222");
141
+ });
142
+
143
+ it("does not replace numbers next to emergency text", () => {
144
+ render(
145
+ `<div><strong>After-hours emergency</strong><span id="en">855-383-2298</span></div>` +
146
+ `<div><strong>Mantenimiento de emergencia</strong><span id="es">855-383-2298</span></div>`
147
+ );
148
+
149
+ insert();
150
+
151
+ expect(textOf("#en")).to.equal("855-383-2298");
152
+ expect(textOf("#es")).to.equal("855-383-2298");
153
+ });
154
+ });
155
+
156
+ describe("hrefs", () => {
157
+ it("rewrites tel: hrefs in any format", () => {
158
+ const hrefs = [
159
+ "tel:8553832298",
160
+ "tel:18553832298",
161
+ "tel:+18553832298",
162
+ "tel:855-383-2298",
163
+ "tel:(855) 383-2298",
164
+ ];
165
+ render(
166
+ hrefs.map((h, i) => `<a id="h${i}" href="${h}">Call</a>`).join("")
167
+ );
168
+
169
+ insert();
170
+
171
+ hrefs.forEach((href, i) => {
172
+ expect(hrefOf(`#h${i}`)).to.equal(`tel:${DNI_NUMBER}`, href);
173
+ });
174
+ });
175
+
176
+ it("rewrites sms, whatsapp, and facetime hrefs", () => {
177
+ render(
178
+ `<a id="sms" href="sms:8553832298">Text</a>` +
179
+ `<a id="wa" href="whatsapp://send?phone=18553832298">WhatsApp</a>` +
180
+ `<a id="ft" href="facetime:8553832298">FaceTime</a>` +
181
+ `<a id="fta" href="facetime-audio:8553832298">FaceTime Audio</a>`
182
+ );
183
+
184
+ insert();
185
+
186
+ expect(hrefOf("#sms")).to.equal(`sms:${DNI_NUMBER}`);
187
+ expect(hrefOf("#wa")).to.equal(`whatsapp://send?phone=${DNI_NUMBER}`);
188
+ expect(hrefOf("#ft")).to.equal(`facetime:${DNI_NUMBER}`);
189
+ expect(hrefOf("#fta")).to.equal(`facetime-audio:${DNI_NUMBER}`);
190
+ });
191
+
192
+ it("leaves non-phone hrefs untouched", () => {
193
+ render(`<a id="n" href="/floor-plans">Floor plans</a>`);
194
+
195
+ insert();
196
+
197
+ expect(hrefOf("#n")).to.equal("/floor-plans");
198
+ });
199
+ });
200
+
201
+ describe("return value", () => {
202
+ it("counts elements whose display text changed", () => {
203
+ render(
204
+ `<span>855-383-2298</span><span>(855) 383-2298</span>` +
205
+ `<a href="tel:8553832298">Call</a><span>123-456-7890</span>`
206
+ );
207
+
208
+ expect(insert()).to.equal(2);
209
+ });
210
+
211
+ it("returns 0 when no phone number is found", () => {
212
+ render(`<p>No phone numbers here, just 39.9038078, -104.9932859.</p>`);
213
+
214
+ expect(insert()).to.equal(0);
215
+ });
216
+ });
217
+ });
@@ -31,7 +31,7 @@ export const insertDNIIntoWebsite = (
31
31
  const newFacetimeAudioNumber = `facetime-audio:${cleanedNumber}`;
32
32
 
33
33
  const phoneNumberPattern =
34
- /(?:\+1[\s.-]?)?(?:\(\d{3}\)[\s.-]?|\d{3}[\s.-])\d{3}[\s.-]?\d{4}/g;
34
+ /(?:\+1[\s.-]?)?(?:\([2-9]\d{2}\)[\s.-]?|[2-9]\d{2}[\s.-])[2-9]\d{2}[\s.-]?\d{4}/g;
35
35
  const hrefNumberPattern =
36
36
  /(tel|sms):(?:\+1\s)?(?:(?:\(\d{3}\)\s|\d{3}-)\d{3}-\d{4}|\d{10,11})/g;
37
37
 
@@ -0,0 +1,216 @@
1
+ import { expect } from "@esm-bundle/chai";
2
+ import {
3
+ replacePageRedirectElementWithTourWidgetTrigger,
4
+ replaceSSTLinkWithTourWidgetTrigger,
5
+ replaceTourTriggerElementWithTourWidgetTrigger,
6
+ } from "./insertLeadSourceIntoSchedulerLinks";
7
+
8
+ const APP = "https://app.meetelise.com";
9
+ const ORG = "test-org";
10
+ const BUILDING = "test-building";
11
+
12
+ const render = (hrefs: string[]): HTMLAnchorElement[] => {
13
+ document.body.innerHTML = hrefs
14
+ .map((href) => `<a href="${href}">link</a>`)
15
+ .join("");
16
+ return Array.from(document.querySelectorAll("a"));
17
+ };
18
+
19
+ // Returns whether the widget cancelled the click. The document listener runs
20
+ // after the link's own listeners, then cancels navigation so the test page stays.
21
+ const click = (element: Element): boolean => {
22
+ let defaultPrevented = false;
23
+ const recordAndCancel = (event: Event) => {
24
+ defaultPrevented = event.defaultPrevented;
25
+ event.preventDefault();
26
+ };
27
+ document.addEventListener("click", recordAndCancel);
28
+ element.dispatchEvent(
29
+ new MouseEvent("click", { bubbles: true, cancelable: true })
30
+ );
31
+ document.removeEventListener("click", recordAndCancel);
32
+ return defaultPrevented;
33
+ };
34
+
35
+ const clickAll = (links: HTMLAnchorElement[]): Record<string, boolean> =>
36
+ Object.fromEntries(
37
+ links.map((link) => [link.getAttribute("href") ?? "", click(link)])
38
+ );
39
+
40
+ const allEqual = (hrefs: string[], value: boolean): Record<string, boolean> =>
41
+ Object.fromEntries(hrefs.map((href) => [href, value]));
42
+
43
+ describe("replaceSSTLinkWithTourWidgetTrigger", () => {
44
+ let opened: MouseEvent[];
45
+ const install = (orgSlug = ORG, buildingSlug = BUILDING): void =>
46
+ replaceSSTLinkWithTourWidgetTrigger(
47
+ (event) => opened.push(event),
48
+ orgSlug,
49
+ buildingSlug,
50
+ () => true
51
+ );
52
+
53
+ beforeEach(() => {
54
+ opened = [];
55
+ });
56
+
57
+ afterEach(() => {
58
+ document.body.innerHTML = "";
59
+ });
60
+
61
+ it("opens the scheduler only for the current building's tour link on a portfolio page", () => {
62
+ const [ownTour, otherTour, contactUs] = render([
63
+ `${APP}/${ORG}/${BUILDING}/tour`,
64
+ `${APP}/${ORG}/other-building/tour`,
65
+ `${APP}/${ORG}/${BUILDING}/contactUs`,
66
+ ]);
67
+
68
+ install();
69
+
70
+ expect({
71
+ ownTour: click(ownTour),
72
+ otherTour: click(otherTour),
73
+ contactUs: click(contactUs),
74
+ }).to.deep.equal({ ownTour: true, otherTour: false, contactUs: false });
75
+ expect(opened).to.have.length(1);
76
+ });
77
+
78
+ it("matches the current building's tour link with query strings, fragments, trailing slashes and any casing", () => {
79
+ const hrefs = [
80
+ `${APP}/${ORG}/${BUILDING}/tour/`,
81
+ `${APP}/${ORG}/${BUILDING}/tour?utm_source=website`,
82
+ `${APP}/${ORG}/${BUILDING}/tour/?hideScrollBars=true`,
83
+ `${APP}/${ORG}/${BUILDING}/tour?stateId=abc123`,
84
+ `${APP}/${ORG}/${BUILDING}/tour#schedule`,
85
+ `${APP}/${ORG}//${BUILDING}/tour`,
86
+ `${APP}/Test-Org/Test-Building/Tour`,
87
+ ];
88
+ const links = render(hrefs);
89
+
90
+ install();
91
+
92
+ expect(clickAll(links)).to.deep.equal(allEqual(hrefs, true));
93
+ expect(opened).to.have.length(hrefs.length);
94
+ });
95
+
96
+ it("matches when the widget was started with differently cased slugs", () => {
97
+ const [link] = render([`${APP}/${ORG}/${BUILDING}/tour`]);
98
+
99
+ install("Test-Org", "TEST-BUILDING");
100
+
101
+ expect(click(link)).to.equal(true);
102
+ });
103
+
104
+ it("keeps default navigation for every other app.meetelise.com link", () => {
105
+ const hrefs = [
106
+ `${APP}/other-org/${BUILDING}/tour`,
107
+ `${APP}/${ORG}/${BUILDING}-2/tour`,
108
+ `${APP}/${ORG}/${BUILDING}/tour-2`,
109
+ `${APP}/${ORG}/${BUILDING}/tour/extra`,
110
+ `${APP}/${ORG}/${BUILDING}/contactUs?utm_source=website`,
111
+ `${APP}/${ORG}/${BUILDING}`,
112
+ `${APP}/${ORG}/tour`,
113
+ `${APP}/${BUILDING}/tour`,
114
+ `${APP}/settings/widgets`,
115
+ `${APP}/`,
116
+ ];
117
+ const links = render(hrefs);
118
+
119
+ install();
120
+
121
+ expect(clickAll(links)).to.deep.equal(allEqual(hrefs, false));
122
+ expect(opened).to.have.length(0);
123
+ });
124
+
125
+ it("never intercepts links outside https://app.meetelise.com", () => {
126
+ const hrefs = [
127
+ `/${ORG}/${BUILDING}/tour`,
128
+ `http://app.meetelise.com/${ORG}/${BUILDING}/tour`,
129
+ `https://www.example.com/${ORG}/${BUILDING}/tour`,
130
+ ];
131
+ const links = render(hrefs);
132
+
133
+ install();
134
+
135
+ expect(clickAll(links)).to.deep.equal(allEqual(hrefs, false));
136
+ });
137
+
138
+ it("lets a removed widget's tour link navigate after the widget switches buildings", () => {
139
+ const [firstTour, secondTour] = render([
140
+ `${APP}/${ORG}/${BUILDING}/tour`,
141
+ `${APP}/${ORG}/other-building/tour`,
142
+ ]);
143
+ const openedByFirst: MouseEvent[] = [];
144
+ const openedBySecond: MouseEvent[] = [];
145
+ let firstMounted = true;
146
+ replaceSSTLinkWithTourWidgetTrigger(
147
+ (event) => openedByFirst.push(event),
148
+ ORG,
149
+ BUILDING,
150
+ () => firstMounted
151
+ );
152
+
153
+ // Studio removes the first widget and starts one for another building.
154
+ firstMounted = false;
155
+ replaceSSTLinkWithTourWidgetTrigger(
156
+ (event) => openedBySecond.push(event),
157
+ ORG,
158
+ "other-building",
159
+ () => true
160
+ );
161
+
162
+ expect({
163
+ firstTour: click(firstTour),
164
+ secondTour: click(secondTour),
165
+ }).to.deep.equal({ firstTour: false, secondTour: true });
166
+ expect(openedByFirst).to.have.length(0);
167
+ expect(openedBySecond).to.have.length(1);
168
+ });
169
+
170
+ it("intercepts nothing when the building slug is missing", () => {
171
+ const hrefs = [`${APP}/${ORG}/${BUILDING}/tour`, `${APP}/${ORG}//tour`];
172
+ const links = render(hrefs);
173
+
174
+ install(ORG, "");
175
+
176
+ expect(clickAll(links)).to.deep.equal(allEqual(hrefs, false));
177
+ expect(opened).to.have.length(0);
178
+ });
179
+ });
180
+
181
+ describe("site schedule-a-tour hooks", () => {
182
+ let opened: MouseEvent[];
183
+
184
+ beforeEach(() => {
185
+ opened = [];
186
+ });
187
+
188
+ afterEach(() => {
189
+ document.body.innerHTML = "";
190
+ });
191
+
192
+ it("opens the scheduler from links ending in schedule-a-tour", () => {
193
+ const [link] = render(["/apartments/oh/westerville/schedule-a-tour"]);
194
+
195
+ replacePageRedirectElementWithTourWidgetTrigger((event) =>
196
+ opened.push(event)
197
+ );
198
+
199
+ expect(click(link)).to.equal(true);
200
+ expect(opened).to.have.length(1);
201
+ });
202
+
203
+ it("replaces #leaseleads-tour-open links with a scheduler trigger", () => {
204
+ render(["#leaseleads-tour-open"]);
205
+
206
+ replaceTourTriggerElementWithTourWidgetTrigger((event) =>
207
+ opened.push(event)
208
+ );
209
+
210
+ const trigger = document.querySelector("a");
211
+ expect(trigger?.hasAttribute("href")).to.equal(false);
212
+ expect(trigger?.textContent).to.equal("link");
213
+ expect(click(trigger as HTMLAnchorElement)).to.equal(true);
214
+ expect(opened).to.have.length(1);
215
+ });
216
+ });
@@ -18,14 +18,43 @@ export const insertLeadSourceIntoSchedulerLinks = (
18
18
  });
19
19
  };
20
20
 
21
+ // Mirrors the web-app's /:organization/:building/tour route, which ignores
22
+ // empty path segments. Case-insensitive so slug casing differences still match.
23
+ const isBuildingTourLink = (
24
+ href: string,
25
+ orgSlug: string,
26
+ buildingSlug: string
27
+ ): boolean => {
28
+ const [org, building, page, ...rest] = new URL(href).pathname
29
+ .split("/")
30
+ .filter(Boolean)
31
+ .map((segment) => segment.toLowerCase());
32
+ return (
33
+ rest.length === 0 &&
34
+ page === "tour" &&
35
+ org === orgSlug.toLowerCase() &&
36
+ building === buildingSlug.toLowerCase()
37
+ );
38
+ };
39
+
21
40
  export const replaceSSTLinkWithTourWidgetTrigger = (
22
- handleTourClicked: (e: MouseEvent) => void
41
+ handleTourClicked: (e: MouseEvent) => void,
42
+ orgSlug: string,
43
+ buildingSlug: string,
44
+ isWidgetMounted: () => boolean
23
45
  ): void => {
24
46
  const sstRedirectElements = document.querySelectorAll("a");
25
47
  sstRedirectElements.forEach(function (element) {
26
48
  const href = element.getAttribute("href");
27
- if (href && href.startsWith("https://app.meetelise.com/")) {
49
+ if (
50
+ href &&
51
+ href.startsWith("https://app.meetelise.com/") &&
52
+ isBuildingTourLink(href, orgSlug, buildingSlug)
53
+ ) {
28
54
  element.addEventListener("click", (event) => {
55
+ // Studio can swap the widget for another building's. A removed
56
+ // widget's listener must let the link navigate, not swallow the click.
57
+ if (!isWidgetMounted()) return;
29
58
  event.preventDefault(); // Prevents the default href redirect
30
59
  handleTourClicked(event as MouseEvent);
31
60
  });