@meetelise/chat 1.11.0 → 1.12.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.
- package/.eslintrc.cjs +1 -0
- package/.github/workflows/release.yml +1 -0
- package/.vscode/settings.json +6 -1
- package/CONTRIBUTING.md +8 -0
- package/package.json +9 -10
- package/public/demo/index.html +78 -11
- package/public/dist/index.js +1714 -1
- package/public/dist/index.js.LICENSE.txt +26 -14
- package/public/index.html +2 -1
- package/src/MEChat.test.ts +5 -5
- package/src/MEChat.ts +53 -0
- package/src/WebComponent/InHouseLauncher.ts +446 -0
- package/src/WebComponent/MEChat.css +5 -0
- package/src/WebComponent/MEChat.ts +282 -0
- package/src/WebComponent/OfficeHours.ts +73 -0
- package/src/WebComponent/Scheduler/date-picker.ts +320 -0
- package/src/WebComponent/Scheduler/me-select.ts +244 -0
- package/src/WebComponent/Scheduler/time-picker.ts +101 -0
- package/src/WebComponent/Scheduler/tour-scheduler.ts +383 -0
- package/src/WebComponent/Scheduler/tour-type-option.ts +92 -0
- package/src/WebComponent/actions/ActionConfirmButton.ts +94 -0
- package/src/WebComponent/actions/CallUsWindow.ts +110 -0
- package/src/WebComponent/actions/DetailsWindow.ts +109 -0
- package/src/WebComponent/actions/EmailUsWindow.ts +432 -0
- package/src/WebComponent/actions/InputStyles.ts +31 -0
- package/src/WebComponent/actions/TextUsWindow.ts +226 -0
- package/src/WebComponent/actions/formatPhoneNumber.ts +42 -0
- package/src/WebComponent/inHouseLauncherStyles.ts +300 -0
- package/src/WebComponent/index.ts +2 -0
- package/src/WebComponent/utils.ts +82 -0
- package/src/fetchBuildingInfo.ts +1 -0
- package/src/getAvailabilities.ts +71 -0
- package/src/themes.ts +5 -3
- package/tsconfig.json +9 -3
- package/web-test-runner.config.js +0 -6
- package/webpack.config.cjs +8 -25
- package/public/dist/index.d.ts +0 -1
- package/public/dist/src/ChatButton.d.ts +0 -9
- package/public/dist/src/ChatIcon.d.ts +0 -6
- package/public/dist/src/InHouseLauncher.d.ts +0 -11
- package/public/dist/src/MEChat.d.ts +0 -73
- package/public/dist/src/analytics.d.ts +0 -34
- package/public/dist/src/assetUrls.d.ts +0 -2
- package/public/dist/src/chatID.d.ts +0 -11
- package/public/dist/src/createConversation.d.ts +0 -4
- package/public/dist/src/fetchBuildingInfo.d.ts +0 -25
- package/public/dist/src/themes.d.ts +0 -52
- package/public/dist/src/utils.d.ts +0 -2
- package/src/ChatButton.module.scss +0 -51
- package/src/ChatButton.tsx +0 -38
- package/src/ChatIcon.tsx +0 -26
- package/src/DemoApp.tsx +0 -113
- package/src/InHouseLauncher.module.scss +0 -139
- package/src/InHouseLauncher.tsx +0 -69
- package/src/MEChat.module.scss +0 -22
- package/src/MEChat.tsx +0 -293
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { css, html, LitElement, TemplateResult } from "lit";
|
|
2
|
+
import { customElement, property, state } from "lit/decorators.js";
|
|
3
|
+
import { classMap } from "lit/directives/class-map.js";
|
|
4
|
+
import { createRef, ref, Ref } from "lit/directives/ref.js";
|
|
5
|
+
import Talk from "talkjs";
|
|
6
|
+
import { InHouseLauncher } from ".";
|
|
7
|
+
import "./Scheduler/tour-scheduler";
|
|
8
|
+
import Analytics from "../analytics";
|
|
9
|
+
import { getChatID } from "../chatID";
|
|
10
|
+
import createConversation from "../createConversation";
|
|
11
|
+
import fetchBuildingInfo, { Building } from "../fetchBuildingInfo";
|
|
12
|
+
import { getTheme, Theme, ThemeIdString } from "../themes";
|
|
13
|
+
import { isMobile } from "../utils";
|
|
14
|
+
import { installInHouseLauncher } from "./InHouseLauncher";
|
|
15
|
+
|
|
16
|
+
import "./MEChat.css";
|
|
17
|
+
|
|
18
|
+
export interface Options {
|
|
19
|
+
building: string;
|
|
20
|
+
organization: string;
|
|
21
|
+
themeId?: ThemeIdString;
|
|
22
|
+
avatarSrc?: string;
|
|
23
|
+
mini?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@customElement("me-chat")
|
|
27
|
+
export class MEChat extends LitElement {
|
|
28
|
+
static styles = css`
|
|
29
|
+
#__talkjs_launcher:not(.shouldBeVisible) {
|
|
30
|
+
display: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.inHouseLauncherContainer.in-house-launcher__mobile {
|
|
34
|
+
width: 100%;
|
|
35
|
+
height: 100px;
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
static session: Promise<Talk.Session> = Talk.ready.then(() => {
|
|
39
|
+
const me = new Talk.User({
|
|
40
|
+
id: "anonymous",
|
|
41
|
+
name: "Me",
|
|
42
|
+
email: null,
|
|
43
|
+
role: "Default",
|
|
44
|
+
});
|
|
45
|
+
return new Talk.Session({
|
|
46
|
+
appId: "ogKIvCor",
|
|
47
|
+
me,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
@property({ type: String })
|
|
51
|
+
private buildingSlug = "";
|
|
52
|
+
@property({ type: String })
|
|
53
|
+
private orgSlug = "";
|
|
54
|
+
@property({ type: String })
|
|
55
|
+
private themeId: string | undefined;
|
|
56
|
+
@property({ attribute: false })
|
|
57
|
+
private avatarSrc?: string | null;
|
|
58
|
+
@property({ type: Boolean })
|
|
59
|
+
useMiniWidget = true;
|
|
60
|
+
|
|
61
|
+
@state()
|
|
62
|
+
private popup: Talk.Popup | null = null;
|
|
63
|
+
@state()
|
|
64
|
+
private theme: Theme = getTheme();
|
|
65
|
+
@state()
|
|
66
|
+
private chatId = "";
|
|
67
|
+
@state()
|
|
68
|
+
private analytics: Analytics | null = null;
|
|
69
|
+
@state()
|
|
70
|
+
private isMobile = isMobile();
|
|
71
|
+
@state()
|
|
72
|
+
private launcher: HTMLElement | null = null;
|
|
73
|
+
@state()
|
|
74
|
+
private building: Building | null = null;
|
|
75
|
+
@state()
|
|
76
|
+
private hasMounted = false;
|
|
77
|
+
@state()
|
|
78
|
+
private hideLauncher = false;
|
|
79
|
+
|
|
80
|
+
private yardiDNIScriptInterval: NodeJS.Timer | null = null;
|
|
81
|
+
inHouseLauncherRef: Ref<InHouseLauncher> = createRef();
|
|
82
|
+
|
|
83
|
+
initializeInstanceVariables = async (): Promise<void> => {
|
|
84
|
+
await this.setBuildingDerivedInfo();
|
|
85
|
+
await this.initializeLaunchJS();
|
|
86
|
+
this.attachOnClickToInHouseLauncher();
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
setBuildingDerivedInfo = async (): Promise<void> => {
|
|
90
|
+
if (!this.buildingSlug || !this.orgSlug) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.building = await fetchBuildingInfo(this.orgSlug, this.buildingSlug);
|
|
94
|
+
this.chatId = getChatID(this.orgSlug, this.buildingSlug);
|
|
95
|
+
this.avatarSrc = this.avatarSrc || this.building.avatarSrc;
|
|
96
|
+
this.theme = getTheme(this.themeId ?? this.building.themeId);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
private initializeLaunchJS = async () => {
|
|
100
|
+
if (!this.building || !this.theme) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (this.popup && this.launcher) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const [building, theme, avatarSrc, session] = await Promise.all([
|
|
108
|
+
this.building,
|
|
109
|
+
this.theme,
|
|
110
|
+
this.avatarSrc,
|
|
111
|
+
MEChat.session,
|
|
112
|
+
]);
|
|
113
|
+
if (building.conversationMaintenanceMode) {
|
|
114
|
+
return new Promise(() => {
|
|
115
|
+
// If in maintenance mode, we return an always-pending Promise
|
|
116
|
+
// eslint-disable-next-line no-console
|
|
117
|
+
console.warn(
|
|
118
|
+
"MeetElise Chat is in maintenance mode. Chat icon will not appear."
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
await this.configureTalkJSPopup(building, theme, session, avatarSrc);
|
|
123
|
+
this.configureLauncherElement();
|
|
124
|
+
|
|
125
|
+
this.yardiDNIScriptInterval = setInterval(
|
|
126
|
+
() => this.pollForYardiCampaignSource(),
|
|
127
|
+
1000
|
|
128
|
+
);
|
|
129
|
+
setTimeout(clearInterval, 15000, this.yardiDNIScriptInterval);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
private pollForYardiCampaignSource() {
|
|
133
|
+
const yardiSource = window.RCTPCampaign?.CampaignDetails?.Source;
|
|
134
|
+
if (yardiSource) {
|
|
135
|
+
this.analytics?.ping("yardi-DNI-init");
|
|
136
|
+
if (this.yardiDNIScriptInterval) {
|
|
137
|
+
clearInterval(this.yardiDNIScriptInterval);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private configureLauncherElement = () => {
|
|
143
|
+
if (!this.popup || !this.inHouseLauncherRef.value) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.launcher = this.inHouseLauncherRef.value;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
private configureTalkJSPopup = async (
|
|
150
|
+
building: Building,
|
|
151
|
+
theme: Theme,
|
|
152
|
+
session: Talk.Session,
|
|
153
|
+
avatarSrc?: string | null
|
|
154
|
+
) => {
|
|
155
|
+
const popup = session.createPopup(
|
|
156
|
+
createConversation(
|
|
157
|
+
session,
|
|
158
|
+
building,
|
|
159
|
+
theme,
|
|
160
|
+
avatarSrc || building.avatarSrc,
|
|
161
|
+
this.chatId,
|
|
162
|
+
this.isMobile
|
|
163
|
+
),
|
|
164
|
+
{
|
|
165
|
+
launcher: "never",
|
|
166
|
+
showCloseInHeader: true,
|
|
167
|
+
messageField: { placeholder: "Ask a question..." },
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
await popup.mount({ show: false });
|
|
172
|
+
popup.on("close", () => {
|
|
173
|
+
this.hideLauncher = false;
|
|
174
|
+
});
|
|
175
|
+
const talkjsPopupElement = document.querySelector(".__talkjs_popup");
|
|
176
|
+
if (!talkjsPopupElement) throw new Error("Failed to find chat window");
|
|
177
|
+
talkjsPopupElement.classList.add("meetelise-chat", "pane");
|
|
178
|
+
if (!this.isMobile) {
|
|
179
|
+
talkjsPopupElement.classList.add("in-house-launcher__desktop");
|
|
180
|
+
}
|
|
181
|
+
(talkjsPopupElement as HTMLElement).style.zIndex = "99999999999";
|
|
182
|
+
this.popup = popup;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Remove the instance from the screen.
|
|
187
|
+
*
|
|
188
|
+
* Chat will be unusable after this. If you just need to hide the
|
|
189
|
+
* chat button, use {@link MEChat#hide} instead.
|
|
190
|
+
*/
|
|
191
|
+
remove(): void {
|
|
192
|
+
this.popup?.destroy();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Open the messages window */
|
|
196
|
+
open(): void {
|
|
197
|
+
this.popup?.show();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Close the messages window */
|
|
201
|
+
close(): void {
|
|
202
|
+
this.popup?.hide();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Show the chat button on the screen if it was previously hidden. */
|
|
206
|
+
// TODO: will this work with the new launcher? it needs to be display flex? will this just change the inline style and leave the stylesheet/style tag alone?
|
|
207
|
+
show(): void {
|
|
208
|
+
if (!this.launcher) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.launcher.style.display = "";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Hide the chat button from the screen (but don't remove from the DOM). */
|
|
215
|
+
hide(): void {
|
|
216
|
+
if (!this.launcher) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.launcher.style.display = "none";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
firstUpdated = (): void => {
|
|
223
|
+
this.initializeInstanceVariables();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
render(): TemplateResult {
|
|
227
|
+
installInHouseLauncher();
|
|
228
|
+
|
|
229
|
+
return html`
|
|
230
|
+
<div
|
|
231
|
+
class=${classMap({
|
|
232
|
+
inHouseLauncherContainer: true,
|
|
233
|
+
["in-house-launcher__mobile"]: this.isMobile,
|
|
234
|
+
["in-house-launcher__desktop"]: !this.isMobile,
|
|
235
|
+
["meetelise-chat"]: true,
|
|
236
|
+
launcher: true,
|
|
237
|
+
shouldBeVisible: true,
|
|
238
|
+
})}
|
|
239
|
+
>
|
|
240
|
+
<in-house-launcher
|
|
241
|
+
${ref(this.inHouseLauncherRef)}
|
|
242
|
+
.isMobile=${this.isMobile}
|
|
243
|
+
.isFirstMount=${!this.hasMounted}
|
|
244
|
+
.isMini=${this.useMiniWidget}
|
|
245
|
+
.buildingId=${this.building?.id ?? 0}
|
|
246
|
+
phoneNumber="${this.building?.phoneNumber ?? ""}"
|
|
247
|
+
textColor="${this.theme.chatHeader.textColor}"
|
|
248
|
+
backgroundColor="${this.theme.chatPaneBackgroundColor}"
|
|
249
|
+
?hidden=${this.hideLauncher}
|
|
250
|
+
>
|
|
251
|
+
</in-house-launcher>
|
|
252
|
+
</div>
|
|
253
|
+
`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private attachOnClickToInHouseLauncher = () => {
|
|
257
|
+
const inHouseLauncher = this.inHouseLauncherRef.value;
|
|
258
|
+
if (!inHouseLauncher) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
inHouseLauncher.onChatTapped = async () => {
|
|
263
|
+
if (!this.popup) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
this.popup.show();
|
|
268
|
+
this.analytics?.ping("open");
|
|
269
|
+
this.hideLauncher = true;
|
|
270
|
+
this.hasMounted = true;
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
declare global {
|
|
276
|
+
interface HTMLElementTagNameMap {
|
|
277
|
+
"me-chat": MEChat;
|
|
278
|
+
}
|
|
279
|
+
interface Window {
|
|
280
|
+
RCTPCampaign?: { CampaignDetails: { Source: string } };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import eachDayOfInterval from "date-fns/eachDayOfInterval";
|
|
2
|
+
import startOfWeek from "date-fns/startOfWeek";
|
|
3
|
+
import endOfWeek from "date-fns/endOfWeek";
|
|
4
|
+
import setHours from "date-fns/setHours";
|
|
5
|
+
import getDay from "date-fns/getDay";
|
|
6
|
+
import axios from "axios";
|
|
7
|
+
|
|
8
|
+
interface OfficeHourAvailabilityReponse {
|
|
9
|
+
[dayOfWeek: string]: { start: string; end: string };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const officeHoursCache: {
|
|
13
|
+
[buildingId: number]: OfficeHourAvailabilityReponse;
|
|
14
|
+
} = {};
|
|
15
|
+
|
|
16
|
+
export const getRawOfficeHours = async (
|
|
17
|
+
buildingId: number
|
|
18
|
+
): Promise<OfficeHourAvailabilityReponse> => {
|
|
19
|
+
if (officeHoursCache[buildingId]) {
|
|
20
|
+
return officeHoursCache[buildingId];
|
|
21
|
+
}
|
|
22
|
+
const url = `https://app.meetelise.com/platformApi/connectors/talk_js/leasing_office_hours/${buildingId}`;
|
|
23
|
+
const result = await axios.get<OfficeHourAvailabilityReponse>(url);
|
|
24
|
+
officeHoursCache[buildingId] = result.data;
|
|
25
|
+
return result.data;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const getDefaultOfficeHours = (): {
|
|
29
|
+
startTime: Date;
|
|
30
|
+
endTime: Date;
|
|
31
|
+
}[] => {
|
|
32
|
+
return eachDayOfInterval({
|
|
33
|
+
start: startOfWeek(new Date()),
|
|
34
|
+
end: endOfWeek(new Date()),
|
|
35
|
+
})
|
|
36
|
+
.filter((date) => getDay(date) !== 0 && getDay(date) !== 6)
|
|
37
|
+
.map((date) => ({
|
|
38
|
+
startTime: setHours(date, 9),
|
|
39
|
+
endTime: setHours(date, 17),
|
|
40
|
+
}));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const dayNamesInOrder = [
|
|
44
|
+
"monday",
|
|
45
|
+
"tuesday",
|
|
46
|
+
"wednesday",
|
|
47
|
+
"thursday",
|
|
48
|
+
"friday",
|
|
49
|
+
"saturday",
|
|
50
|
+
"sunday",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Based on the design, we're going with monday as the first day of the week
|
|
55
|
+
*/
|
|
56
|
+
export const getOfficeHourText = async (
|
|
57
|
+
buildingId: number
|
|
58
|
+
): Promise<
|
|
59
|
+
(string | { dayOfWeek: string; startTime: string; endTime: string })[]
|
|
60
|
+
> => {
|
|
61
|
+
const rawOfficeHours = await getRawOfficeHours(buildingId);
|
|
62
|
+
return dayNamesInOrder.map((dow) => {
|
|
63
|
+
const officeHourInfo = rawOfficeHours[dow];
|
|
64
|
+
if (!officeHourInfo) {
|
|
65
|
+
return dow;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
dayOfWeek: dow,
|
|
69
|
+
startTime: officeHourInfo.start,
|
|
70
|
+
endTime: officeHourInfo.end,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
};
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { LitElement, html, TemplateResult, css } from "lit";
|
|
2
|
+
import { property, state } from "lit/decorators.js";
|
|
3
|
+
import { classMap } from "lit/directives/class-map.js";
|
|
4
|
+
import {
|
|
5
|
+
dayNames,
|
|
6
|
+
getDaysInMonth,
|
|
7
|
+
getDaysInPreviousMonth,
|
|
8
|
+
getMonthEndDay,
|
|
9
|
+
getMonthStartDay,
|
|
10
|
+
Month,
|
|
11
|
+
monthNames,
|
|
12
|
+
} from "../utils";
|
|
13
|
+
|
|
14
|
+
export class DatePicker extends LitElement {
|
|
15
|
+
/**
|
|
16
|
+
* Optional attribute to set the date picker's default month.
|
|
17
|
+
* The attribute will not be updated if the user changes the month shown.
|
|
18
|
+
*/
|
|
19
|
+
@property({ attribute: "month", type: String })
|
|
20
|
+
defaultMonth?: Month;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Optional attribute to set the date picker's default year.
|
|
24
|
+
* The attribute will not be updated if the user changes the year shown.
|
|
25
|
+
*/
|
|
26
|
+
@property({ attribute: "year", type: Number })
|
|
27
|
+
defaultYear?: number;
|
|
28
|
+
|
|
29
|
+
private _selectedDate?: number = undefined;
|
|
30
|
+
set selectedDate(date: number | undefined) {
|
|
31
|
+
const old = this._selectedDate;
|
|
32
|
+
this._selectedDate = date;
|
|
33
|
+
this.requestUpdate("selectedDate", old);
|
|
34
|
+
this.dispatchEvent(new Event("change"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@property({ type: Number })
|
|
38
|
+
get selectedDate(): undefined | number {
|
|
39
|
+
return this._selectedDate;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
now = new Date();
|
|
43
|
+
|
|
44
|
+
private _monthShown =
|
|
45
|
+
this.defaultMonth && monthNames.indexOf(this.defaultMonth) !== -1
|
|
46
|
+
? monthNames.indexOf(this.defaultMonth)
|
|
47
|
+
: this.now.getMonth();
|
|
48
|
+
|
|
49
|
+
// Make it easy to increment/decrement `this.monthShown` at year boundaries
|
|
50
|
+
set monthShown(month: number) {
|
|
51
|
+
const oldMonthShown = this._monthShown;
|
|
52
|
+
if (month === 12) {
|
|
53
|
+
this._monthShown = 0;
|
|
54
|
+
this.yearShown++;
|
|
55
|
+
} else if (month === -1) {
|
|
56
|
+
this._monthShown = 11;
|
|
57
|
+
this.yearShown--;
|
|
58
|
+
} else {
|
|
59
|
+
this._monthShown = month;
|
|
60
|
+
}
|
|
61
|
+
this.requestUpdate("monthShown", oldMonthShown);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 0-indexed month, e.g. `0` for January */
|
|
65
|
+
@state()
|
|
66
|
+
get monthShown(): number {
|
|
67
|
+
return this._monthShown;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@state()
|
|
71
|
+
yearShown = this.defaultYear ?? this.now.getFullYear();
|
|
72
|
+
|
|
73
|
+
static styles = css`
|
|
74
|
+
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700;900&display=swap");
|
|
75
|
+
|
|
76
|
+
:host {
|
|
77
|
+
box-sizing: border-box;
|
|
78
|
+
font-family: "Poppins";
|
|
79
|
+
color: #202020;
|
|
80
|
+
font-style: normal;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#calendar {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-direction: column;
|
|
86
|
+
user-select: none;
|
|
87
|
+
min-width: 206px;
|
|
88
|
+
width: 206px;
|
|
89
|
+
background: #e7e7e7;
|
|
90
|
+
border: 1px solid #ffffff;
|
|
91
|
+
border-radius: 10px;
|
|
92
|
+
padding: 15px 12px 10px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#header {
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
justify-content: space-between;
|
|
99
|
+
margin-bottom: 9px;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
h1 {
|
|
103
|
+
font-weight: 600;
|
|
104
|
+
font-size: 12px;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#arrows {
|
|
108
|
+
width: 40.46px;
|
|
109
|
+
display: flex;
|
|
110
|
+
justify-content: space-between;
|
|
111
|
+
align-items: center;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#rows {
|
|
115
|
+
display: flex;
|
|
116
|
+
flex-direction: column;
|
|
117
|
+
gap: 10px;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.row {
|
|
121
|
+
display: flex;
|
|
122
|
+
justify-content: space-between;
|
|
123
|
+
align-items: center;
|
|
124
|
+
width: 100%;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.dayNumber,
|
|
128
|
+
.dayInitial {
|
|
129
|
+
position: relative;
|
|
130
|
+
font-size: 12px;
|
|
131
|
+
text-align: center;
|
|
132
|
+
flex: 1 1 0px;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.dayInitial {
|
|
136
|
+
font-weight: 400;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.dayNumber {
|
|
140
|
+
font-weight: 600;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.dayNumber.selected::after,
|
|
144
|
+
.dayNumber:not(.selected):hover::after {
|
|
145
|
+
content: "";
|
|
146
|
+
height: 23px;
|
|
147
|
+
aspect-ratio: 1;
|
|
148
|
+
border-radius: 50%;
|
|
149
|
+
background-color: #202020;
|
|
150
|
+
position: absolute;
|
|
151
|
+
top: 50%;
|
|
152
|
+
left: 50%;
|
|
153
|
+
transform: translate(-50%, -50%);
|
|
154
|
+
z-index: -1;
|
|
155
|
+
}
|
|
156
|
+
.dayNumber:not(.selected):hover::after {
|
|
157
|
+
background-color: lightgray;
|
|
158
|
+
}
|
|
159
|
+
.dayNumber.selected::after {
|
|
160
|
+
background-color: #202020;
|
|
161
|
+
}
|
|
162
|
+
.dayNumber.selected,
|
|
163
|
+
.dayNumber:not(.selected):not(.differentMonth):hover {
|
|
164
|
+
z-index: 1;
|
|
165
|
+
}
|
|
166
|
+
.dayNumber.selected {
|
|
167
|
+
color: white;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.dayNumber.differentMonth {
|
|
171
|
+
color: #84838f;
|
|
172
|
+
}
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
render(): TemplateResult {
|
|
176
|
+
// TODO: check 0 vs. 1-indexing, make sure this works.
|
|
177
|
+
// TODO: handle year boundaries, e.g. if it's January and last month was last year. also same for next month when switching months.
|
|
178
|
+
const daysInMonth = getDaysInMonth(this.yearShown, this.monthShown);
|
|
179
|
+
const monthStartDay =
|
|
180
|
+
dayNames[getMonthStartDay(this.yearShown, this.monthShown)];
|
|
181
|
+
const daysInPreviousMonth = getDaysInPreviousMonth(
|
|
182
|
+
this.yearShown,
|
|
183
|
+
this.monthShown
|
|
184
|
+
);
|
|
185
|
+
const monthEndDay =
|
|
186
|
+
dayNames[getMonthEndDay(this.yearShown, this.monthShown)];
|
|
187
|
+
const extraDaysAtBeginningOfMonth = [];
|
|
188
|
+
if (monthStartDay !== "Sunday") {
|
|
189
|
+
const numberOfExtraDaysAtBeginningOfMonth =
|
|
190
|
+
dayNames.indexOf(monthStartDay);
|
|
191
|
+
for (let i = numberOfExtraDaysAtBeginningOfMonth; i > 0; i--) {
|
|
192
|
+
extraDaysAtBeginningOfMonth.push(daysInPreviousMonth + 1 - i);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const daysOfMonth = new Array(daysInMonth).fill(null).map((_, i) => i + 1);
|
|
197
|
+
|
|
198
|
+
const extraDaysAtEndOfMonth = [];
|
|
199
|
+
if (monthEndDay !== "Saturday") {
|
|
200
|
+
const numberOfExtraDaysAtEndOfMonth = [...dayNames]
|
|
201
|
+
.reverse()
|
|
202
|
+
.indexOf(monthEndDay);
|
|
203
|
+
for (let i = 1; i <= numberOfExtraDaysAtEndOfMonth; i++) {
|
|
204
|
+
extraDaysAtEndOfMonth.push(i);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const dayNums = [
|
|
209
|
+
...extraDaysAtBeginningOfMonth,
|
|
210
|
+
...daysOfMonth,
|
|
211
|
+
...extraDaysAtEndOfMonth,
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
const dayElements = dayNums.map((dayNumber, index) => {
|
|
215
|
+
const isDifferentMonth =
|
|
216
|
+
index < extraDaysAtBeginningOfMonth.length ||
|
|
217
|
+
index >= extraDaysAtBeginningOfMonth.length + daysOfMonth.length;
|
|
218
|
+
return html`<span
|
|
219
|
+
class="dayNumber ${classMap({
|
|
220
|
+
differentMonth: isDifferentMonth,
|
|
221
|
+
selected: dayNumber === this.selectedDate && !isDifferentMonth,
|
|
222
|
+
})}"
|
|
223
|
+
>${dayNumber}</span
|
|
224
|
+
>`;
|
|
225
|
+
});
|
|
226
|
+
const rows = chunk(7, dayElements);
|
|
227
|
+
|
|
228
|
+
return html`
|
|
229
|
+
<div id="calendar">
|
|
230
|
+
<div id="header">
|
|
231
|
+
<h1>${monthNames[this.monthShown]} ${this.yearShown}</h1>
|
|
232
|
+
<div
|
|
233
|
+
id="arrows"
|
|
234
|
+
@click="${(e: MouseEvent) => {
|
|
235
|
+
// TODO: disable incrementing/decrementing to months with no appointments available
|
|
236
|
+
if ((e.target as HTMLElement)?.closest("#back")) {
|
|
237
|
+
this.monthShown--;
|
|
238
|
+
}
|
|
239
|
+
if ((e.target as HTMLElement)?.closest("#forward")) {
|
|
240
|
+
this.monthShown++;
|
|
241
|
+
}
|
|
242
|
+
}}"
|
|
243
|
+
@keydown="${(e: KeyboardEvent) => {
|
|
244
|
+
if (![" ", "Enter"].includes(e.key)) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if ((e.target as HTMLElement)?.closest("#back")) {
|
|
248
|
+
e.preventDefault();
|
|
249
|
+
e.stopPropagation();
|
|
250
|
+
this.monthShown--;
|
|
251
|
+
} else if ((e.target as HTMLElement)?.closest("#forward")) {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
e.stopPropagation();
|
|
254
|
+
this.monthShown++;
|
|
255
|
+
}
|
|
256
|
+
}}"
|
|
257
|
+
>
|
|
258
|
+
<svg
|
|
259
|
+
id="back"
|
|
260
|
+
tabindex="0"
|
|
261
|
+
width="9"
|
|
262
|
+
height="16"
|
|
263
|
+
viewBox="0 0 9 16"
|
|
264
|
+
fill="none"
|
|
265
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
266
|
+
>
|
|
267
|
+
<path
|
|
268
|
+
d="M8.67727 2.34317L7.26305 0.928955L0.192017 8.00001L7.26308 15.0711L8.6773 13.6569L3.02044 8L8.67727 2.34317Z"
|
|
269
|
+
fill="#83818E"
|
|
270
|
+
/>
|
|
271
|
+
</svg>
|
|
272
|
+
<svg
|
|
273
|
+
id="forward"
|
|
274
|
+
tabindex="0"
|
|
275
|
+
width="9"
|
|
276
|
+
height="16"
|
|
277
|
+
viewBox="0 0 9 16"
|
|
278
|
+
fill="none"
|
|
279
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
280
|
+
>
|
|
281
|
+
<path
|
|
282
|
+
d="M0.157227 2.34315L1.57144 0.928932L8.64251 8L1.57144 15.0711L0.157227 13.6569L5.81408 8L0.157227 2.34315Z"
|
|
283
|
+
fill="#83818E"
|
|
284
|
+
/>
|
|
285
|
+
</svg>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<div
|
|
290
|
+
id="rows"
|
|
291
|
+
@click="${(e: MouseEvent) => {
|
|
292
|
+
const target = e.target as HTMLElement | undefined;
|
|
293
|
+
if (target?.closest("span.dayNumber:not(.differentMonth)"))
|
|
294
|
+
this.selectedDate = parseInt(target.innerText);
|
|
295
|
+
}}"
|
|
296
|
+
>
|
|
297
|
+
<div class="row">
|
|
298
|
+
<span class="dayInitial">S</span>
|
|
299
|
+
<span class="dayInitial">M</span>
|
|
300
|
+
<span class="dayInitial">T</span>
|
|
301
|
+
<span class="dayInitial">W</span>
|
|
302
|
+
<span class="dayInitial">T</span>
|
|
303
|
+
<span class="dayInitial">F</span>
|
|
304
|
+
<span class="dayInitial">S</span>
|
|
305
|
+
</div>
|
|
306
|
+
${rows.map((row) => html`<div class="row">${row}</div>`)}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
`;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
customElements.define("date-picker", DatePicker);
|
|
313
|
+
|
|
314
|
+
const chunk = <T>(chunkLength: number, array: T[]) => {
|
|
315
|
+
const chunks = [];
|
|
316
|
+
for (let i = 0; i < array.length; i += chunkLength) {
|
|
317
|
+
chunks.push(array.slice(i, i + chunkLength));
|
|
318
|
+
}
|
|
319
|
+
return chunks;
|
|
320
|
+
};
|