@meetelise/chat 1.12.1 → 1.12.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.
@@ -1,25 +1,278 @@
1
1
  import { css, html, LitElement, TemplateResult } from "lit";
2
- import { customElement, property, state } from "lit/decorators.js";
2
+ import { customElement, property, query, state } from "lit/decorators.js";
3
+ import {
4
+ shortcutKeyIsPressed,
5
+ formatToPhone,
6
+ isPrintableCharacter,
7
+ } from "../actions/formatPhoneNumber";
3
8
  import "./tour-type-option.ts";
4
9
  import "./date-picker.ts";
5
10
  import "./time-picker.ts";
6
11
  import "./me-select.ts";
12
+ import { getAvailabilitiesGroupedByDayCached } from "../../getAvailabilities";
13
+ import { TourAvailabilityResponseRankOrderedSupportedTourTypesEnum } from "@meetelise/rest-sdk";
14
+ import { format } from "date-fns";
15
+ import { DatePicker } from "./date-picker";
16
+ import { MESelect } from "./me-select";
17
+ import { TimePicker } from "./time-picker";
18
+ import { LabeledOption } from "../../fetchBuildingInfo";
19
+ import { isMobile } from "../../utils";
20
+ import axios from "axios";
7
21
 
8
22
  @customElement("tour-scheduler")
9
23
  export class TourScheduler extends LitElement {
10
24
  @property({ attribute: false })
11
- availability?: Availability; // TODO: shouldn't be optional?
12
-
25
+ layoutOptions: LabeledOption[] = [];
26
+ @property({ attribute: false })
27
+ tourTypeOptions: LabeledOption[] = [];
28
+ @property({ type: Number })
29
+ buildingId = 0;
13
30
  @property({ attribute: false })
14
- onSchedule?: (tour: Tour) => void;
31
+ onCloseClicked?: (e: MouseEvent) => void;
15
32
 
16
- // state: type, date, time, name, email, phone, unit type, layout type
17
33
  @state()
18
34
  private tourType = TourType.Guided;
35
+ @state()
36
+ private email = "";
37
+ @state()
38
+ private phoneNumber = "";
39
+ @state()
40
+ private hasPhoneNumberError = false;
41
+ @state()
42
+ private availabilitiesGroupedByDay: { [day: string]: Date[] } = {};
43
+ @state()
44
+ private selectedDate?: Date;
45
+ @state()
46
+ private selectedTime?: string;
47
+ @state()
48
+ private mobilePageIndex = 0;
49
+ @state()
50
+ private tourIsBooked = false;
51
+
52
+ @query("input#name")
53
+ nameInput!: HTMLInputElement;
54
+ @query("input#email")
55
+ emailInput!: HTMLInputElement;
56
+ @query("input#phone")
57
+ phoneInput!: HTMLInputElement;
58
+ @query("me-select#unitType")
59
+ unitTypeSelect!: MESelect;
60
+
61
+ firstUpdated = async (): Promise<void> => {
62
+ this.availabilitiesGroupedByDay = await getAvailabilitiesGroupedByDayCached(
63
+ tourTypeMap[this.tourType]
64
+ );
65
+ };
66
+
67
+ handlePhoneKeydown = (e: Event): void => {
68
+ // these should always be true, this is just here to mollify TypeScript
69
+ if (
70
+ !(e instanceof KeyboardEvent) ||
71
+ !(e.target instanceof HTMLInputElement) ||
72
+ e.target.selectionStart === null
73
+ // !e.target.selectionStart
74
+ )
75
+ return;
76
+
77
+ const cursorPosition = e.target.selectionStart;
78
+
79
+ if (isPrintableCharacter(e) && !shortcutKeyIsPressed(e)) {
80
+ // If e.key is a character, and no modifier key is pressed, insert it at the cursor, filter out non-numbers, and auto-format
81
+ e.preventDefault();
82
+ e.stopPropagation();
83
+ const updated =
84
+ this.phoneNumber.slice(0, cursorPosition) +
85
+ e.key +
86
+ this.phoneNumber.slice(cursorPosition);
87
+ this.phoneNumber = formatToPhone(updated.replace(/\D/g, ""));
88
+ this.phoneInput.value = this.phoneNumber;
89
+ } else if (e.key === "Backspace") {
90
+ /*
91
+ Handling backspace:
92
+ - A single backspace should delete the last digit before the cursor, not just a punctuation character; the user shouldn't interact directly with the punctuation
93
+ - Let the OS handle backspace combos like `Alt + Backspace`, then re-autoformat if necessary (in keyup)
94
+ - If the user wants to select and backspace a range of text, let them, then auto-format the remainder
95
+ */
96
+
97
+ // backspace combos
98
+ if (shortcutKeyIsPressed(e)) {
99
+ return;
100
+ }
101
+
102
+ // backspace selection
103
+ if (
104
+ this.phoneInput.selectionEnd &&
105
+ this.phoneInput.selectionStart &&
106
+ this.phoneInput.selectionEnd - this.phoneInput.selectionStart > 0
107
+ ) {
108
+ return;
109
+ }
110
+
111
+ // regular backspace
112
+ const originalCharacterCount = this.phoneNumber.length;
113
+ const digitsBeforeCursor = this.phoneNumber
114
+ .slice(0, cursorPosition)
115
+ .replace(/\D/g, "");
116
+ const digitsAfterCursor = this.phoneNumber
117
+ .slice(cursorPosition)
118
+ .replace(/\D/g, "");
119
+ const updatedDigits = `${digitsBeforeCursor.slice(
120
+ 0,
121
+ -1
122
+ )}${digitsAfterCursor}`;
123
+ this.phoneNumber = formatToPhone(updatedDigits);
124
+ this.phoneInput.value = this.phoneNumber;
125
+ const numOfCharactersDeleted =
126
+ originalCharacterCount - this.phoneNumber.length;
127
+ const newCursorPosition = cursorPosition - numOfCharactersDeleted;
128
+ this.phoneInput.setSelectionRange(newCursorPosition, newCursorPosition);
129
+ e.preventDefault();
130
+ e.stopPropagation();
131
+ return;
132
+ } else if (
133
+ ["ArrowLeft", "ArrowRight"].includes(e.key) &&
134
+ !shortcutKeyIsPressed(e) &&
135
+ !e.shiftKey
136
+ ) {
137
+ // when navigating with arrow keys, skip punctuation
138
+ if (e.key === "ArrowLeft") {
139
+ const charactersBeforeCursor = this.phoneNumber.slice(
140
+ 0,
141
+ cursorPosition
142
+ );
143
+ const numberOfNonDigitsBeforeCursor =
144
+ charactersBeforeCursor.split(/\d+/).at(-1)?.length || 0;
145
+ const moveLeftBy = numberOfNonDigitsBeforeCursor + 1;
146
+ const newCursorPosition =
147
+ cursorPosition - moveLeftBy > -1 ? cursorPosition - moveLeftBy : 0;
148
+ this.phoneInput.setSelectionRange(newCursorPosition, newCursorPosition);
149
+ }
150
+ if (e.key === "ArrowRight") {
151
+ const charactersAfterCursor = this.phoneNumber.slice(cursorPosition);
152
+ const numberOfNonDigitsAfterCursor =
153
+ charactersAfterCursor.split(/\d+/)[0].length || 0;
154
+ const moveRightBy = numberOfNonDigitsAfterCursor + 1;
155
+ const newCursorPosition =
156
+ cursorPosition + moveRightBy < this.phoneNumber.length
157
+ ? cursorPosition + moveRightBy
158
+ : this.phoneNumber.length;
159
+ this.phoneInput.setSelectionRange(newCursorPosition, newCursorPosition);
160
+ }
161
+ e.preventDefault();
162
+ e.stopPropagation();
163
+ } else {
164
+ // Let browser/OS handle anything else. We'll handle any changes to the phone input in the `keyup` handler.
165
+ // Could be a keyboard shortcut that modifies the input (like `Cmd/Ctrl + V`, which we'll handle in `keyup`),
166
+ // or a keyboard shortcut that doesn't (like `Cmd + L` to jump to URL bar or `Cmd + R` to reload the page),
167
+ // or Tab, an arrow key, etc.
168
+ return;
169
+ }
170
+ };
171
+
172
+ handlePhoneKeyup = (e: KeyboardEvent): void => {
173
+ // After formatting, place the cursor where it was before, defined as "before the digit that followed it before formatting, if any, otherwise at the end".
174
+ // (We never want the cursor to be before a punctuation mark because the next digit typed will appear after the punctuation mark, not before.)
175
+ // If we don't do this, the cursor automatically goes to the end when we set `this.phoneNumber`.
176
+ // This is sometimes undesired: for example, if we've ended up here because a Mac user typed `Alt + Backspace` in the middle.
177
+
178
+ // Arrow keys are intended to change the cursor position, so don't get in their way
179
+ if (
180
+ e.key.includes("Arrow") ||
181
+ ["Meta", "Shift", "Control", "Alt"].includes(e.key)
182
+ ) {
183
+ return;
184
+ }
19
185
 
20
- static styles = css`
21
- @import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700;900&display=swap");
186
+ const cursorPosition = this.phoneInput.selectionStart;
187
+ // find the numbers it's before and count backward from end after formatting
188
+ const numbersAfterCursor = cursorPosition
189
+ ? this.phoneInput.value.slice(cursorPosition).replace(/\D/g, "")
190
+ : "";
191
+ this.phoneNumber = formatToPhone(this.phoneInput.value);
192
+
193
+ // EXAMPLES: (123)| 4 numbersAfterCursor will be '4'.
194
+ let cursorNegativeIndex = 0;
195
+ let numbersLeft = numbersAfterCursor.length;
196
+ while (numbersLeft) {
197
+ if (this.phoneNumber.at(cursorNegativeIndex)?.match(/\d/)) {
198
+ numbersLeft--;
199
+ }
200
+ cursorNegativeIndex++;
201
+ }
202
+ const cursorPositiveIndex =
203
+ this.phoneInput.value.length - cursorNegativeIndex + 1;
204
+ this.phoneInput.setSelectionRange(cursorPositiveIndex, cursorPositiveIndex);
205
+ };
206
+
207
+ onChangeEmail = (e: Event): void => {
208
+ if (!e.target) {
209
+ return;
210
+ }
211
+
212
+ this.email = (e.target as HTMLInputElement).value;
213
+ };
214
+
215
+ validators = {
216
+ tourType: (): boolean => !isNaN(this.tourType),
217
+ dateAndTime: (): boolean => !!this.selectedDate && !!this.selectedTime,
218
+ leadInfo: (): boolean =>
219
+ !!this.nameInput?.value &&
220
+ this.emailInput?.value.includes("@") &&
221
+ // TODO: deleting phone number doesn't cause validation to fail, at least on mobile
222
+ !!this.phoneNumber &&
223
+ this.phoneNumber.length === 14,
224
+ unitType: (): boolean => !!this.unitTypeSelect.value,
225
+ };
226
+
227
+ formIsValidForSubmission = (): boolean => {
228
+ const isValid =
229
+ this.validators.tourType() &&
230
+ this.validators.dateAndTime() &&
231
+ this.validators.leadInfo() &&
232
+ this.validators.unitType();
233
+ return isValid;
234
+ };
235
+
236
+ /** E.g., `timeStringToHoursAndMinutes("4:15pm")` -> `[16, 15]`
237
+ */
238
+ timeStringToHoursAndMinutes = (
239
+ timeString: string
240
+ ): [hours: number, minutes: number] => {
241
+ const [hoursString, minutesString] = timeString.split(/\D/g);
242
+ const hours =
243
+ parseInt(hoursString) +
244
+ (timeString.toLowerCase().includes("pm") ? 12 : 0);
245
+ const minutes = parseInt(minutesString);
246
+
247
+ return [hours, minutes];
248
+ };
249
+
250
+ submit = async (): Promise<void> => {
251
+ if (!this.selectedDate || !this.selectedTime) return;
252
+ const [hours, minutes] = this.timeStringToHoursAndMinutes(
253
+ this.selectedTime
254
+ );
255
+ const tourTime = new Date(this.selectedDate.getTime());
256
+ tourTime.setHours(hours, minutes);
257
+ const tourTimeString = tourTime.toISOString();
258
+ const data = {
259
+ email_address: this.email,
260
+ phone_number: `+1${this.phoneNumber.match(/\d/g)?.join("")}`, // e.g. +12125555555
261
+ building_id: this.buildingId,
262
+ // TODO: this is very bad dumb name-splitting logic! I'm only doing it because the design had one name field and the backend expects two
263
+ first_name: this.nameInput.value.split(" ")[0],
264
+ last_name: this.nameInput.value.split(" ").slice(1).join(" "),
265
+ tour_type: tourTypeForSubmission[this.tourType],
266
+ tour_time: tourTimeString,
267
+ };
268
+ const url = `https://app.meetelise.com/platformApi/state/create/scheduleMe`;
269
+ const response = await axios.post(url, data);
270
+ if (response.status === 200) {
271
+ this.tourIsBooked = true;
272
+ }
273
+ };
22
274
 
275
+ static styles = css`
23
276
  * {
24
277
  box-sizing: border-box;
25
278
  }
@@ -36,11 +289,11 @@ export class TourScheduler extends LitElement {
36
289
  border-radius: 10px;
37
290
  font-family: "Poppins";
38
291
  color: #202020;
39
- padding: 0 27px 0 25px;
292
+ padding: 0 25px 0 27px;
40
293
 
41
294
  /* grid stuff */
42
295
  display: grid;
43
- grid-template-columns: 225px 400px 330px;
296
+ grid-template-columns: 229px 432px 305px;
44
297
  grid-template-rows: 44px 54px 32px 195px 152px 1px;
45
298
  }
46
299
 
@@ -62,16 +315,26 @@ export class TourScheduler extends LitElement {
62
315
  grid-row: 1 / 2;
63
316
  grid-column: 1;
64
317
  align-self: end;
318
+ z-index: 1; // idk why, but it's invisible on the confirmation page otherwise
65
319
  }
66
320
 
67
321
  button#closeButton {
68
322
  grid-row: 1 / 2;
69
- grid-column: -1;
70
- height: max-content;
71
- aspect-ratio: 1;
323
+ grid-column: 3;
72
324
  background: none;
73
325
  border: none;
74
326
  align-self: end;
327
+ justify-self: end;
328
+ z-index: 1; // idk why, but it's invisible on the confirmation page otherwise
329
+ }
330
+
331
+ /*
332
+ makes button fit size of SVG:
333
+ https://stackoverflow.com/questions/45423874/button-height-is-greater-than-the-nested-contents-height
334
+ otherwise there's some empty space at the bottom of the button, which interferes with vertical centering
335
+ */
336
+ button#closeButton > svg {
337
+ vertical-align: middle;
75
338
  }
76
339
 
77
340
  #tourTypeMenu {
@@ -129,9 +392,6 @@ export class TourScheduler extends LitElement {
129
392
  height: 49px;
130
393
  border: 1px solid #83818e;
131
394
  padding: 13px 11px 14px 11px;
132
- }
133
-
134
- #yourInformationMenu > input::placeholder {
135
395
  font-family: "Poppins";
136
396
  font-style: normal;
137
397
  font-weight: 400;
@@ -139,6 +399,10 @@ export class TourScheduler extends LitElement {
139
399
  color: #202020;
140
400
  }
141
401
 
402
+ #yourInformationMenu > input::placeholder {
403
+ color: #202020;
404
+ }
405
+
142
406
  #unitChoiceMenu {
143
407
  grid-row: 5 / 6;
144
408
  grid-column: 3;
@@ -177,11 +441,10 @@ export class TourScheduler extends LitElement {
177
441
  padding-top: 32px;
178
442
  }
179
443
 
180
- /* TODO: button styling depends on disabled which depends on whether form is ready for submission */
181
444
  button#schedule {
182
445
  width: 145px;
183
446
  height: 50px;
184
- background: #e7e7e7;
447
+ background: #202020;
185
448
  border: 1px solid #ffffff;
186
449
  border-radius: 10px;
187
450
  grid-row: 7;
@@ -193,180 +456,456 @@ export class TourScheduler extends LitElement {
193
456
  font-weight: 700;
194
457
  font-size: 14px;
195
458
  color: #ffffff;
459
+ box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.5);
460
+ }
461
+
462
+ button#schedule:disabled {
463
+ background: #e7e7e7;
464
+ box-shadow: none;
465
+ }
466
+
467
+ #confirmationMessage {
468
+ grid-row: 3;
469
+ width: 625px;
470
+ }
471
+
472
+ #confirmationMessage > p {
473
+ font-size: 18px;
474
+ }
475
+
476
+ @media (max-width: 767px) {
477
+ /* TODO: separate styles into general, desktop-specific, and mobile-specific.
478
+ basically everything I have "unset" or "initial" on should become desktop-specific. the grid layout is only for desktop.
479
+ */
480
+ .tour-scheduler {
481
+ position: fixed;
482
+ left: 0;
483
+ right: 0;
484
+ bottom: 0;
485
+ top: initial;
486
+ height: 93vh;
487
+ width: 100vw;
488
+ background: #ffffff;
489
+ transform: none;
490
+ box-shadow: none;
491
+ border-radius: 0;
492
+ padding: 25px 20px 0 22px;
493
+ display: flex;
494
+ flex-direction: column;
495
+ }
496
+
497
+ #topControls {
498
+ display: flex;
499
+ justify-content: space-between;
500
+ align-items: center;
501
+ }
502
+
503
+ .tour-scheduler > :is(h1, h2) {
504
+ align-self: unset;
505
+ }
506
+
507
+ /* TODO: this and :disabled is duplicated from Schedule button. make a class,
508
+ or better a component, for the button styles
509
+ */
510
+ button#next {
511
+ height: 50px;
512
+ /* width: 74px; */
513
+ padding: 13px 22px 14px 22px;
514
+ align-self: flex-start;
515
+ background: #202020;
516
+ border: 1px solid #ffffff;
517
+ border-radius: 10px;
518
+ font-family: "Poppins";
519
+ font-weight: 700;
520
+ font-size: 14px;
521
+ color: #ffffff;
522
+ box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.5);
523
+ margin-top: 22px;
524
+ }
525
+
526
+ button#next:disabled {
527
+ background: #e7e7e7;
528
+ box-shadow: none;
529
+ }
530
+
531
+ h1#scheduleATour {
532
+ grid-row: unset;
533
+ grid-column: unset;
534
+ align-self: unset;
535
+ }
536
+
537
+ button#closeButton {
538
+ grid-row: unset;
539
+ grid-column: unset;
540
+ align-self: unset;
541
+ justify-self: unset;
542
+ }
543
+
544
+ h2 {
545
+ grid-row: unset;
546
+ margin-top: 37px;
547
+ margin-bottom: 25px;
548
+ }
549
+
550
+ #tourTypeMenu {
551
+ display: flex;
552
+ flex-direction: column;
553
+ gap: 15px;
554
+ margin-bottom: 36px;
555
+ }
556
+
557
+ h2#tourType {
558
+ grid-column: unset;
559
+ grid-row: unset;
560
+ }
561
+
562
+ #datePicker {
563
+ display: flex;
564
+ flex-direction: column;
565
+ gap: 18px;
566
+ }
567
+
568
+ time-picker {
569
+ /* so the Next button doesn't jump when the date is selected and the time slots appear */
570
+ height: 93px;
571
+ }
572
+
573
+ #dateAndTimeMenu {
574
+ grid-row: unset;
575
+ grid-column: unset;
576
+ align-self: unset;
577
+ display: unset;
578
+ flex-direction: unset;
579
+ }
580
+
581
+ #confirmationMessage {
582
+ grid-row: unset;
583
+ width: 90%;
584
+ margin-top: 37px;
585
+ }
196
586
  }
197
587
  `;
198
588
 
199
- render(): TemplateResult {
200
- return html`
201
- <div class="tour-scheduler">
202
- <h1 id="scheduleATour">Schedule a tour</h1>
203
- <button id="closeButton">
204
- <svg
205
- width="19"
206
- height="19"
207
- viewBox="0 0 19 19"
208
- fill="none"
209
- xmlns="http://www.w3.org/2000/svg"
210
- >
211
- <line
212
- x1="0.986863"
213
- y1="18.2746"
214
- x2="18.2929"
215
- y2="0.968593"
216
- stroke="#202020"
217
- stroke-width="2"
218
- />
219
- <path
220
- d="M1.01394 0.999997L18.0103 18.0243"
221
- stroke="#202020"
222
- stroke-width="2"
223
- />
224
- </svg>
225
- </button>
226
-
227
- <h2 id="tourType">Tour Type</h2>
228
- <div id="tourTypeMenu">
229
- <tour-type-option
230
- tourtype="guided"
231
- heading="Guided tour"
232
- subtitle="with an agent"
233
- @click="${() => {
234
- this.tourType = TourType.Guided;
235
- }}"
236
- @keydown="${(e: KeyboardEvent) => {
237
- if ([" ", "Enter"].includes(e.key)) {
238
- e.preventDefault();
589
+ tourTypeMenu(): TemplateResult {
590
+ return html`<h2 id="tourType">Tour Type</h2>
591
+ <div id="tourTypeMenu">
592
+ ${this.tourTypeOptions.map((o) => o.value).includes("WITH_AGENT")
593
+ ? html` <tour-type-option
594
+ tourtype="guided"
595
+ heading="Guided tour"
596
+ subtitle="with an agent"
597
+ @click="${() => {
239
598
  this.tourType = TourType.Guided;
240
- }
241
- }}"
242
- ?selected="${this.tourType === TourType.Guided}"
243
- >
244
- <svg
245
- slot="icon"
246
- width="31"
247
- height="31"
248
- viewBox="0 0 31 31"
249
- fill="none"
250
- xmlns="http://www.w3.org/2000/svg"
599
+ }}"
600
+ @keydown="${(e: KeyboardEvent) => {
601
+ if ([" ", "Enter"].includes(e.key)) {
602
+ e.preventDefault();
603
+ this.tourType = TourType.Guided;
604
+ }
605
+ }}"
606
+ ?selected="${this.tourType === TourType.Guided}"
251
607
  >
252
- <path
253
- d="M0.833252 30.1666C0.833252 27.1608 2.0273 24.2782 4.15271 22.1527C6.27812 20.0273 9.1608 18.8333 12.1666 18.8333C15.1724 18.8333 18.0551 20.0273 20.1805 22.1527C22.3059 24.2782 23.4999 27.1608 23.4999 30.1666H0.833252ZM12.1666 17.4166C7.47034 17.4166 3.66659 13.6129 3.66659 8.91663C3.66659 4.22038 7.47034 0.416626 12.1666 0.416626C16.8628 0.416626 20.6666 4.22038 20.6666 8.91663C20.6666 13.6129 16.8628 17.4166 12.1666 17.4166ZM22.5975 20.58C24.7645 21.137 26.7006 22.3634 28.13 24.0846C29.5595 25.8059 30.4096 27.9342 30.5592 30.1666H26.3333C26.3333 26.4691 24.9166 23.1031 22.5975 20.58ZM19.7316 17.3557C20.9187 16.2939 21.8681 14.9932 22.5175 13.5388C23.167 12.0845 23.5017 10.5094 23.4999 8.91663C23.5029 6.9807 23.0078 5.07657 22.062 3.38738C23.6666 3.70979 25.11 4.5779 26.1469 5.84415C27.1838 7.1104 27.7502 8.69666 27.7499 10.3333C27.7503 11.3426 27.5349 12.3404 27.1182 13.2597C26.7016 14.179 26.0932 14.9986 25.3339 15.6636C24.5746 16.3285 23.6819 16.8236 22.7157 17.1154C21.7495 17.4072 20.7321 17.4892 19.7316 17.3557V17.3557Z"
254
- fill="${this.tourType === TourType.Guided
255
- ? "#ffffff"
256
- : "#202020"}"
257
- />
258
- </svg>
259
- </tour-type-option>
260
- <tour-type-option
261
- tourtype="self"
262
- heading="Take a tour"
263
- subtitle="on your own"
264
- @click="${() => {
265
- this.tourType = TourType.Self;
266
- }}"
267
- @keydown="${(e: KeyboardEvent) => {
268
- if ([" ", "Enter"].includes(e.key)) {
269
- e.preventDefault();
608
+ <svg
609
+ slot="icon"
610
+ width="31"
611
+ height="31"
612
+ viewBox="0 0 31 31"
613
+ fill="none"
614
+ xmlns="http://www.w3.org/2000/svg"
615
+ >
616
+ <path
617
+ d="M0.833252 30.1666C0.833252 27.1608 2.0273 24.2782 4.15271 22.1527C6.27812 20.0273 9.1608 18.8333 12.1666 18.8333C15.1724 18.8333 18.0551 20.0273 20.1805 22.1527C22.3059 24.2782 23.4999 27.1608 23.4999 30.1666H0.833252ZM12.1666 17.4166C7.47034 17.4166 3.66659 13.6129 3.66659 8.91663C3.66659 4.22038 7.47034 0.416626 12.1666 0.416626C16.8628 0.416626 20.6666 4.22038 20.6666 8.91663C20.6666 13.6129 16.8628 17.4166 12.1666 17.4166ZM22.5975 20.58C24.7645 21.137 26.7006 22.3634 28.13 24.0846C29.5595 25.8059 30.4096 27.9342 30.5592 30.1666H26.3333C26.3333 26.4691 24.9166 23.1031 22.5975 20.58ZM19.7316 17.3557C20.9187 16.2939 21.8681 14.9932 22.5175 13.5388C23.167 12.0845 23.5017 10.5094 23.4999 8.91663C23.5029 6.9807 23.0078 5.07657 22.062 3.38738C23.6666 3.70979 25.11 4.5779 26.1469 5.84415C27.1838 7.1104 27.7502 8.69666 27.7499 10.3333C27.7503 11.3426 27.5349 12.3404 27.1182 13.2597C26.7016 14.179 26.0932 14.9986 25.3339 15.6636C24.5746 16.3285 23.6819 16.8236 22.7157 17.1154C21.7495 17.4072 20.7321 17.4892 19.7316 17.3557V17.3557Z"
618
+ fill="${this.tourType === TourType.Guided
619
+ ? "#ffffff"
620
+ : "#202020"}"
621
+ />
622
+ </svg>
623
+ </tour-type-option>`
624
+ : ""}
625
+ ${this.tourTypeOptions.map((o) => o.value).includes("SELF_GUIDED")
626
+ ? html`<tour-type-option
627
+ tourtype="self"
628
+ heading="Take a tour"
629
+ subtitle="on your own"
630
+ @click="${() => {
270
631
  this.tourType = TourType.Self;
271
- }
272
- }}"
273
- ?selected="${this.tourType === TourType.Self}"
274
- >
275
- <svg
276
- slot="icon"
277
- width="28"
278
- height="31"
279
- viewBox="0 0 28 31"
280
- fill="none"
281
- xmlns="http://www.w3.org/2000/svg"
632
+ }}"
633
+ @keydown="${(e: KeyboardEvent) => {
634
+ if ([" ", "Enter"].includes(e.key)) {
635
+ e.preventDefault();
636
+ this.tourType = TourType.Self;
637
+ }
638
+ }}"
639
+ ?selected="${this.tourType === TourType.Self}"
282
640
  >
283
- <path
284
- d="M14.8334 19.1903V30.1667H0.666687C0.666248 28.4367 1.06183 26.7297 1.82311 25.1763C2.58439 23.6229 3.69118 22.2644 5.05866 21.2049C6.42614 20.1453 8.01802 19.4129 9.71232 19.0637C11.4066 18.7145 13.1584 18.7578 14.8334 19.1903ZM12 17.4167C7.30377 17.4167 3.50002 13.6129 3.50002 8.91666C3.50002 4.22041 7.30377 0.416656 12 0.416656C16.6963 0.416656 20.5 4.22041 20.5 8.91666C20.5 13.6129 16.6963 17.4167 12 17.4167ZM20.5 23.0833V18.125L27.5834 24.5L20.5 30.875V25.9167H16.25V23.0833H20.5Z"
285
- fill="${this.tourType === TourType.Self
286
- ? "#ffffff"
287
- : "#202020"}"
288
- />
289
- </svg>
290
- </tour-type-option>
291
- <tour-type-option
292
- tourtype="guided"
293
- heading="Virtual tour"
294
- subtitle="over video"
295
- @click="${() => {
296
- this.tourType = TourType.Virtual;
297
- }}"
298
- @keydown="${(e: KeyboardEvent) => {
299
- if ([" ", "Enter"].includes(e.key)) {
300
- e.preventDefault();
641
+ <svg
642
+ slot="icon"
643
+ width="28"
644
+ height="31"
645
+ viewBox="0 0 28 31"
646
+ fill="none"
647
+ xmlns="http://www.w3.org/2000/svg"
648
+ >
649
+ <path
650
+ d="M14.8334 19.1903V30.1667H0.666687C0.666248 28.4367 1.06183 26.7297 1.82311 25.1763C2.58439 23.6229 3.69118 22.2644 5.05866 21.2049C6.42614 20.1453 8.01802 19.4129 9.71232 19.0637C11.4066 18.7145 13.1584 18.7578 14.8334 19.1903ZM12 17.4167C7.30377 17.4167 3.50002 13.6129 3.50002 8.91666C3.50002 4.22041 7.30377 0.416656 12 0.416656C16.6963 0.416656 20.5 4.22041 20.5 8.91666C20.5 13.6129 16.6963 17.4167 12 17.4167ZM20.5 23.0833V18.125L27.5834 24.5L20.5 30.875V25.9167H16.25V23.0833H20.5Z"
651
+ fill="${this.tourType === TourType.Self
652
+ ? "#ffffff"
653
+ : "#202020"}"
654
+ />
655
+ </svg>
656
+ </tour-type-option>`
657
+ : ""}
658
+ ${this.tourTypeOptions.map((o) => o.value).includes("SELF_GUIDED")
659
+ ? html`<tour-type-option
660
+ tourtype="guided"
661
+ heading="Virtual tour"
662
+ subtitle="over video"
663
+ @click="${() => {
301
664
  this.tourType = TourType.Virtual;
302
- }
303
- }}"
304
- ?selected="${this.tourType === TourType.Virtual}"
305
- >
306
- <svg
307
- slot="icon"
308
- width="26"
309
- height="25"
310
- viewBox="0 0 26 25"
311
- fill="none"
312
- xmlns="http://www.w3.org/2000/svg"
665
+ }}"
666
+ @keydown="${(e: KeyboardEvent) => {
667
+ if ([" ", "Enter"].includes(e.key)) {
668
+ e.preventDefault();
669
+ this.tourType = TourType.Virtual;
670
+ }
671
+ }}"
672
+ ?selected="${this.tourType === TourType.Virtual}"
313
673
  >
314
- <path
315
- d="M15.6 19.5V22.1L18.2 23.4V24.7H7.80004L7.79484 23.4052L10.4 22.1V19.5H1.28965C1.11894 19.4989 0.950113 19.4642 0.792841 19.3979C0.635568 19.3315 0.492943 19.2347 0.373141 19.1131C0.253339 18.9915 0.158715 18.8474 0.0946936 18.6892C0.0306718 18.5309 -0.00148955 18.3616 5.29696e-05 18.1909V1.3091C5.29696e-05 0.586299 0.591552 0 1.28965 0H24.7104C25.4228 0 26 0.583699 26 1.3091V18.1909C26 18.9137 25.4085 19.5 24.7104 19.5H15.6ZM2.60005 14.3V16.9H23.4V14.3H2.60005Z"
316
- fill="${this.tourType === TourType.Virtual
317
- ? "#ffffff"
318
- : "#202020"}"
319
- />
320
- </svg>
321
- </tour-type-option>
322
- </div>
674
+ <svg
675
+ slot="icon"
676
+ width="26"
677
+ height="25"
678
+ viewBox="0 0 26 25"
679
+ fill="none"
680
+ xmlns="http://www.w3.org/2000/svg"
681
+ >
682
+ <path
683
+ d="M15.6 19.5V22.1L18.2 23.4V24.7H7.80004L7.79484 23.4052L10.4 22.1V19.5H1.28965C1.11894 19.4989 0.950113 19.4642 0.792841 19.3979C0.635568 19.3315 0.492943 19.2347 0.373141 19.1131C0.253339 18.9915 0.158715 18.8474 0.0946936 18.6892C0.0306718 18.5309 -0.00148955 18.3616 5.29696e-05 18.1909V1.3091C5.29696e-05 0.586299 0.591552 0 1.28965 0H24.7104C25.4228 0 26 0.583699 26 1.3091V18.1909C26 18.9137 25.4085 19.5 24.7104 19.5H15.6ZM2.60005 14.3V16.9H23.4V14.3H2.60005Z"
684
+ fill="${this.tourType === TourType.Virtual
685
+ ? "#ffffff"
686
+ : "#202020"}"
687
+ />
688
+ </svg>
689
+ </tour-type-option>`
690
+ : ""}
691
+ </div>`;
692
+ }
323
693
 
324
- <h2 id="dateAndTime">Date and Time</h2>
325
- <div id="dateAndTimeMenu">
326
- <!-- TODO: pass currently selected date as attribute if any -->
327
- <div id="datePicker">
328
- <date-picker></date-picker>
329
- <time-picker></time-picker>
330
- </div>
694
+ dateAndTimeMenu(horizontal = false): TemplateResult {
695
+ return html`<h2 id="dateAndTime">Date and Time</h2>
696
+ <div id="dateAndTimeMenu">
697
+ <div id="datePicker">
698
+ <date-picker
699
+ .availabilities=${this.availabilitiesGroupedByDay}
700
+ @change=${(e: Event) => {
701
+ if (e.target instanceof DatePicker) {
702
+ this.selectedDate = e.target.selectedDate;
703
+ }
704
+ }}
705
+ ></date-picker>
706
+ <time-picker
707
+ ?selecteddateexists=${!!this.selectedDate}
708
+ ?horizontal=${horizontal}
709
+ .options=${this.selectedDate
710
+ ? this.availabilitiesGroupedByDay[
711
+ format(this.selectedDate, "y-MM-dd")
712
+ ]?.map((date) => format(date, "h:mmaaa"))
713
+ : []}
714
+ @change=${(e: Event) => {
715
+ if (e.target instanceof TimePicker) {
716
+ this.selectedTime = e.target.selectedTime;
717
+ }
718
+ }}
719
+ ></time-picker>
331
720
  </div>
721
+ </div>`;
722
+ }
332
723
 
333
- <h2 id="yourInformation">Your information</h2>
334
- <div id="yourInformationMenu">
335
- <input type="text" placeholder="Name" />
336
- <input type="email" placeholder="Email" />
337
- <input type="tel" placeholder="Phone" />
338
- </div>
339
- <div id="unitChoiceMenu">
340
- <h2 id="unitChoice">What would you like to view?</h2>
341
- <div id="unitOptions">
342
- <me-select
343
- id="unitType"
344
- placeholder="Select type"
345
- .options="${["Studio", "1 Bedroom", "2 Bedroom", "3 Bedroom"]}"
346
- defaultOption="Studio"
347
- >Studio
348
- </me-select>
349
- <me-select
350
- id="layout"
351
- placeholder="Select layout"
352
- .options="${["Standard layout", "Weird layout"]}"
353
- >
354
- </me-select>
355
- </div>
724
+ closeButton(): TemplateResult {
725
+ return html`<button id="closeButton" @click=${this.onCloseClicked}>
726
+ <svg
727
+ width="19"
728
+ height="19"
729
+ viewBox="0 0 19 19"
730
+ fill="none"
731
+ xmlns="http://www.w3.org/2000/svg"
732
+ >
733
+ <line
734
+ x1="0.986863"
735
+ y1="18.2746"
736
+ x2="18.2929"
737
+ y2="0.968593"
738
+ stroke="#202020"
739
+ stroke-width="2"
740
+ />
741
+ <path
742
+ d="M1.01394 0.999997L18.0103 18.0243"
743
+ stroke="#202020"
744
+ stroke-width="2"
745
+ />
746
+ </svg>
747
+ </button>`;
748
+ }
749
+
750
+ mobilePages = [
751
+ {
752
+ validate: this.validators.tourType,
753
+ nextButtonText: "Next",
754
+ nextButtonAction: (): void => {
755
+ this.mobilePageIndex++;
756
+ },
757
+ render: (): TemplateResult => {
758
+ return html`${this.tourTypeMenu()}`;
759
+ },
760
+ },
761
+ {
762
+ validate: this.validators.dateAndTime,
763
+ nextButtonText: "Next",
764
+ nextButtonAction: (): void => {
765
+ this.mobilePageIndex++;
766
+ },
767
+ render: (): TemplateResult => {
768
+ return html`${this.dateAndTimeMenu(true)}`;
769
+ },
770
+ },
771
+ {
772
+ validate: (): boolean =>
773
+ this.validators.leadInfo() && this.validators.unitType(),
774
+ nextButtonText: "Schedule tour",
775
+ nextButtonAction: this.submit,
776
+ render: (): TemplateResult => {
777
+ return html`${this.userInfoAndLayoutMenu()}`;
778
+ },
779
+ },
780
+ ];
781
+
782
+ userInfoAndLayoutMenu(): TemplateResult {
783
+ return html`<h2 id="yourInformation">Your information</h2>
784
+ <div id="yourInformationMenu">
785
+ <input type="text" placeholder="Name" id="name" />
786
+ <input
787
+ type="email"
788
+ inputmode="email"
789
+ placeholder="Email"
790
+ id="email"
791
+ .value=${this.email}
792
+ @keyup=${this.onChangeEmail}
793
+ />
794
+ <input
795
+ type="tel"
796
+ inputmode="tel"
797
+ placeholder="Phone"
798
+ id="phone"
799
+ maxlength="14"
800
+ .value=${this.phoneNumber}
801
+ @keydown=${this.handlePhoneKeydown}
802
+ @keyup=${this.handlePhoneKeyup}
803
+ @change=${() => this.requestUpdate()}
804
+ />
805
+ </div>
806
+ <div id="unitChoiceMenu">
807
+ <h2 id="unitChoice">What would you like to view?</h2>
808
+ <div id="unitOptions">
809
+ <!-- TODO: display option.label, store option.value to send to backend -->
810
+ <me-select
811
+ id="unitType"
812
+ placeholder="Select type"
813
+ .options="${this.layoutOptions.map((o) => o.label)}"
814
+ defaultOption="Studio"
815
+ @change=${() => {
816
+ // to revalidate the form
817
+ this.requestUpdate();
818
+ }}
819
+ >Studio
820
+ </me-select>
356
821
  </div>
357
- <hr />
358
- <p id="explanation">
359
- We’ll send a confirmation and any follow-ups to your email address.
822
+ </div>`;
823
+ }
824
+
825
+ confirmationMessage(): TemplateResult {
826
+ if (!this.selectedDate) return html``;
827
+ // format example: "November 9th, 2022 at 11:00am"
828
+ const readableDateAndTime = `${format(
829
+ this.selectedDate,
830
+ "MMMM Lo, y"
831
+ )} at ${this.selectedTime}`;
832
+ return html`
833
+ <div id="confirmationMessage">
834
+ <svg
835
+ width="20"
836
+ height="20"
837
+ viewBox="0 0 20 20"
838
+ fill="none"
839
+ xmlns="http://www.w3.org/2000/svg"
840
+ >
841
+ <path
842
+ d="M7 0V2H13V0H15V2H19C19.2652 2 19.5196 2.10536 19.7071 2.29289C19.8946 2.48043 20 2.73478 20 3V19C20 19.2652 19.8946 19.5196 19.7071 19.7071C19.5196 19.8946 19.2652 20 19 20H1C0.734784 20 0.48043 19.8946 0.292893 19.7071C0.105357 19.5196 0 19.2652 0 19V3C0 2.73478 0.105357 2.48043 0.292893 2.29289C0.48043 2.10536 0.734784 2 1 2H5V0H7ZM18 9H2V18H18V9ZM13.036 10.136L14.45 11.55L9.5 16.5L5.964 12.964L7.38 11.55L9.501 13.672L13.037 10.136H13.036ZM5 4H2V7H18V4H15V5H13V4H7V5H5V4Z"
843
+ fill="#202020"
844
+ />
845
+ </svg>
846
+ <p>
847
+ Thank you!
848
+ <br />
849
+ Your guided tour is scheduled for ${readableDateAndTime}.
850
+ </p>
851
+ <p>
852
+ Look for an email confirmation along with instructions and directions.
360
853
  </p>
361
- <button id="schedule">Schedule tour</button>
854
+ <p>You can make changes at any time, just reply to the email.</p>
362
855
  </div>
363
856
  `;
364
857
  }
365
- }
366
858
 
367
- // TODO: what should this look like? what date format? etc.
368
- interface Availability {
369
- [date: string]: { time: Date }[];
859
+ render(): TemplateResult {
860
+ if (!isMobile()) {
861
+ return html`
862
+ <div class="tour-scheduler">
863
+ <h1 id="scheduleATour">Schedule a tour</h1>
864
+ ${this.closeButton()}
865
+ ${this.tourIsBooked
866
+ ? html`
867
+ <div class="tour-scheduler">${this.confirmationMessage()}</div>
868
+ `
869
+ : html`${this.tourTypeMenu()} ${this.dateAndTimeMenu()}
870
+ ${this.userInfoAndLayoutMenu()}
871
+ <hr />
872
+ <p id="explanation">
873
+ We’ll send a confirmation and any follow-ups to your email
874
+ address.
875
+ </p>
876
+ <button
877
+ id="schedule"
878
+ ?disabled=${!this.formIsValidForSubmission()}
879
+ @click=${this.submit}
880
+ >
881
+ Schedule tour
882
+ </button>`}
883
+ </div>
884
+ `;
885
+ } else {
886
+ const currentPage = this.mobilePages[this.mobilePageIndex];
887
+ return html`
888
+ <div class="tour-scheduler">
889
+ <div id="topControls">
890
+ <h1 id="scheduleATour">Schedule a tour</h1>
891
+ ${this.closeButton()}
892
+ </div>
893
+ ${this.tourIsBooked
894
+ ? this.confirmationMessage()
895
+ : html`${currentPage.render()}
896
+ <button
897
+ id="next"
898
+ @click=${currentPage.nextButtonAction}
899
+ ?disabled=${(() => {
900
+ return !currentPage.validate();
901
+ })()}
902
+ >
903
+ ${currentPage.nextButtonText}
904
+ </button>`}
905
+ </div>
906
+ `;
907
+ }
908
+ }
370
909
  }
371
910
 
372
911
  export enum TourType {
@@ -375,9 +914,19 @@ export enum TourType {
375
914
  Virtual,
376
915
  }
377
916
 
378
- interface Tour {
379
- start: Date;
380
- type: TourType;
381
- // TODO: enum? or leave as string for flexibility? or autogen from backend?
382
- layout: string;
383
- }
917
+ // TODO: we have three UI options and five TourAvailabilityResponseRankOrderedSupportedTourTypesEnum values
918
+ // how should they map?
919
+ const tourTypeMap = {
920
+ [TourType.Guided]:
921
+ TourAvailabilityResponseRankOrderedSupportedTourTypesEnum.WithAgent,
922
+ [TourType.Self]:
923
+ TourAvailabilityResponseRankOrderedSupportedTourTypesEnum.SelfGuided,
924
+ [TourType.Virtual]:
925
+ TourAvailabilityResponseRankOrderedSupportedTourTypesEnum.VirtualShowing,
926
+ };
927
+
928
+ const tourTypeForSubmission = {
929
+ [TourType.Guided]: "escorted-tour",
930
+ [TourType.Self]: "self-guided-tour",
931
+ [TourType.Virtual]: "live-virtual-tour",
932
+ };