pr360-questionnaire 2.1.6 → 2.1.10

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/js/app.js CHANGED
@@ -24,8 +24,9 @@ import topbar from "../vendor/topbar"
24
24
  import Prism from './prism';
25
25
  import 'mermaid-chart';
26
26
  import Sortable from "../vendor/Sortable.js"
27
- import { ShowHistory } from "../vendor/ShowHistory.js";
28
27
  import "./tooltip_position.js";
28
+ import PhoenixCustomEvent from 'phoenix-custom-event-hook';
29
+ import live_select from "live_select";
29
30
 
30
31
  const PrismHook = {
31
32
  mounted() { this.highlight() },
@@ -42,15 +43,304 @@ const SortableHook = {
42
43
  ghostClass: "drag-ghost",
43
44
  forceFallback: true,
44
45
  onEnd: e => {
45
- let params = {old: e.oldIndex, new: e.newIndex, ...e.item.dataset}
46
- this.pushEventTo(this.el, "reposition", params)
46
+ // Custom logic for rules list
47
+ if (this.el.id === "rules-sortable-list") {
48
+ // Collect the new order of rule positions (or ids)
49
+ const newOrder = Array.from(this.el.querySelectorAll(".row-container")).map(div => div.dataset.id);
50
+ this.pushEventTo(this.el, "reorder_rules", {order: newOrder});
51
+ } else {
52
+ // Default behavior for other sortable lists
53
+ let params = {old: e.oldIndex, new: e.newIndex, ...e.item.dataset}
54
+ this.pushEventTo(this.el, "reposition", params)
55
+ }
47
56
  }
48
57
  })
49
58
  }
50
59
  }
51
60
 
61
+ const TableTooltipHook = {
62
+ mounted() {
63
+ this.tooltip = null;
64
+ this.tooltipTimeout = null;
65
+
66
+ this.handleMouseEnter = this.handleMouseEnter.bind(this);
67
+ this.handleMouseMove = this.handleMouseMove.bind(this);
68
+ this.handleMouseLeave = this.handleMouseLeave.bind(this);
69
+
70
+ this.el.addEventListener('mouseenter', this.handleMouseEnter);
71
+ this.el.addEventListener('mousemove', this.handleMouseMove);
72
+ this.el.addEventListener('mouseleave', this.handleMouseLeave);
73
+ },
74
+
75
+ destroyed() {
76
+ this.el.removeEventListener('mouseenter', this.handleMouseEnter);
77
+ this.el.removeEventListener('mousemove', this.handleMouseMove);
78
+ this.el.removeEventListener('mouseleave', this.handleMouseLeave);
79
+ this.removeTooltip();
80
+ },
81
+
82
+ handleMouseEnter(event) {
83
+ const content = this.el.textContent.trim();
84
+ const title = this.el.getAttribute('title');
85
+ const displayText = title || content;
86
+
87
+ // Only show tooltip if content is actually truncated
88
+ if (this.el.scrollWidth > this.el.clientWidth || displayText !== content) {
89
+ this.showTooltip(event, displayText);
90
+ }
91
+ },
92
+
93
+ handleMouseMove(event) {
94
+ if (this.tooltip) {
95
+ this.positionTooltip(event);
96
+ }
97
+ },
98
+
99
+ handleMouseLeave() {
100
+ this.removeTooltip();
101
+ },
102
+
103
+ showTooltip(event, text) {
104
+ this.removeTooltip();
105
+
106
+ this.tooltip = document.createElement('div');
107
+ this.tooltip.className = 'table-tooltip';
108
+ this.tooltip.textContent = text;
109
+ this.tooltip.style.cssText = `
110
+ position: fixed;
111
+ background-color: #007bff;
112
+ color: #fff;
113
+ padding: 12px;
114
+ border-radius: 6px;
115
+ font-size: 13px;
116
+ white-space: nowrap;
117
+ z-index: 1060;
118
+ pointer-events: none;
119
+ max-width: 300px;
120
+ word-wrap: break-word;
121
+ white-space: normal;
122
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
123
+ text-align: left;
124
+ line-height: 1.4;
125
+ `;
126
+
127
+ // Add arrow element
128
+ this.arrow = document.createElement('div');
129
+ this.arrow.style.cssText = `
130
+ position: absolute;
131
+ left: 20px;
132
+ bottom: -16px;
133
+ border-width: 8px;
134
+ border-style: solid;
135
+ border-color: #007bff transparent transparent transparent;
136
+ `;
137
+
138
+ this.tooltip.appendChild(this.arrow);
139
+ document.body.appendChild(this.tooltip);
140
+ this.positionTooltip(event);
141
+ },
142
+
143
+ positionTooltip(event) {
144
+ if (!this.tooltip) return;
145
+
146
+ const rect = this.tooltip.getBoundingClientRect();
147
+ const viewportWidth = window.innerWidth;
148
+ const viewportHeight = window.innerHeight;
149
+
150
+ // Position tooltip above the cursor by default (like status history)
151
+ let left = event.clientX - 20; // Align arrow with cursor
152
+ let top = event.clientY - rect.height - 24; // Position above with margin for arrow
153
+
154
+ // Adjust if tooltip would go off screen
155
+ if (left + rect.width > viewportWidth - 20) {
156
+ left = viewportWidth - rect.width - 20;
157
+ }
158
+
159
+ if (left < 20) {
160
+ left = 20;
161
+ }
162
+
163
+ // If tooltip would go off screen at the top, position it below
164
+ if (top < 20) {
165
+ top = event.clientY + 20;
166
+ // Update arrow to point upward
167
+ if (this.arrow) {
168
+ this.arrow.style.cssText = `
169
+ position: absolute;
170
+ left: 20px;
171
+ top: -16px;
172
+ border-width: 8px;
173
+ border-style: solid;
174
+ border-color: transparent transparent #007bff transparent;
175
+ `;
176
+ }
177
+ } else {
178
+ // Arrow points downward (default)
179
+ if (this.arrow) {
180
+ this.arrow.style.cssText = `
181
+ position: absolute;
182
+ left: 20px;
183
+ bottom: -16px;
184
+ border-width: 8px;
185
+ border-style: solid;
186
+ border-color: #007bff transparent transparent transparent;
187
+ `;
188
+ }
189
+ }
190
+
191
+ this.tooltip.style.left = `${left}px`;
192
+ this.tooltip.style.top = `${top}px`;
193
+ },
194
+
195
+ removeTooltip() {
196
+ if (this.tooltip) {
197
+ document.body.removeChild(this.tooltip);
198
+ this.tooltip = null;
199
+ this.arrow = null;
200
+ }
201
+ }
202
+ }
203
+
204
+ const ProspectsTableSyncHook = {
205
+ mounted() {
206
+ this.mainTable = document.getElementById('prospects-main');
207
+ this.stickyTable = document.getElementById('prospects-sticky');
208
+
209
+ if (!this.mainTable || !this.stickyTable) return;
210
+
211
+ this.handleMainTableHover = this.handleMainTableHover.bind(this);
212
+ this.handleStickyTableHover = this.handleStickyTableHover.bind(this);
213
+ this.syncRowHeights = this.syncRowHeights.bind(this);
214
+ this.handleInputOrResize = this.handleInputOrResize.bind(this);
215
+
216
+ // Add event listeners to main table rows
217
+ this.mainTable.querySelectorAll('tbody tr').forEach(row => {
218
+ row.addEventListener('mouseenter', this.handleMainTableHover);
219
+ row.addEventListener('mouseleave', this.handleMainTableHover);
220
+ });
221
+ // Add event listeners to sticky table rows
222
+ this.stickyTable.querySelectorAll('tbody tr').forEach(row => {
223
+ row.addEventListener('mouseenter', this.handleStickyTableHover);
224
+ row.addEventListener('mouseleave', this.handleStickyTableHover);
225
+ });
226
+
227
+ // Sync row heights initially and on resize
228
+ this.syncRowHeights();
229
+ window.addEventListener('resize', this.syncRowHeights);
230
+
231
+ // Listen for input/resize events in main table (for expanding textareas)
232
+ this.mainTable.querySelectorAll('input, textarea').forEach(el => {
233
+ el.addEventListener('input', this.handleInputOrResize);
234
+ });
235
+
236
+ // MutationObserver to watch for modal open/close
237
+ this.modalObserver = new MutationObserver(() => {
238
+ // Defer to next frame to allow DOM/layout to settle
239
+ requestAnimationFrame(this.syncRowHeights);
240
+ });
241
+ // Watch for changes to the modal-blur-overlay (added/removed)
242
+ this.modalObserver.observe(document.body, {
243
+ childList: true,
244
+ subtree: true
245
+ });
246
+ },
247
+
248
+ destroyed() {
249
+ if (this.mainTable) {
250
+ this.mainTable.querySelectorAll('tbody tr').forEach(row => {
251
+ row.removeEventListener('mouseenter', this.handleMainTableHover);
252
+ row.removeEventListener('mouseleave', this.handleMainTableHover);
253
+ });
254
+ this.mainTable.querySelectorAll('input, textarea').forEach(el => {
255
+ el.removeEventListener('input', this.handleInputOrResize);
256
+ });
257
+ }
258
+ if (this.stickyTable) {
259
+ this.stickyTable.querySelectorAll('tbody tr').forEach(row => {
260
+ row.removeEventListener('mouseenter', this.handleStickyTableHover);
261
+ row.removeEventListener('mouseleave', this.handleStickyTableHover);
262
+ });
263
+ }
264
+ window.removeEventListener('resize', this.syncRowHeights);
265
+ if (this.modalObserver) {
266
+ this.modalObserver.disconnect();
267
+ }
268
+ },
269
+
270
+ syncRowHeights() {
271
+ // Sync header row height
272
+ const mainHeader = this.mainTable.querySelector('thead tr');
273
+ const stickyHeader = this.stickyTable.querySelector('thead tr');
274
+ if (mainHeader && stickyHeader) {
275
+ const mainHeaderHeight = mainHeader.offsetHeight;
276
+ stickyHeader.style.height = mainHeaderHeight + 'px';
277
+ stickyHeader.style.minHeight = mainHeaderHeight + 'px';
278
+ stickyHeader.style.maxHeight = mainHeaderHeight + 'px';
279
+ }
280
+ // Sync body row heights
281
+ const mainRows = this.mainTable.querySelectorAll('tbody tr');
282
+ const stickyRows = this.stickyTable.querySelectorAll('tbody tr');
283
+ for (let i = 0; i < mainRows.length; i++) {
284
+ const mainHeight = mainRows[i].offsetHeight;
285
+ stickyRows[i].style.height = mainHeight + 'px';
286
+ stickyRows[i].style.minHeight = mainHeight + 'px';
287
+ stickyRows[i].style.maxHeight = mainHeight + 'px';
288
+ }
289
+ },
290
+
291
+ handleInputOrResize() {
292
+ // Defer to next frame to allow textarea/input to resize first
293
+ requestAnimationFrame(this.syncRowHeights);
294
+ },
295
+
296
+ handleMainTableHover(event) {
297
+ const prospectId = event.target.closest('tr').dataset.prospectId;
298
+ if (!prospectId) return;
299
+ const stickyRow = this.stickyTable.querySelector(`tr[data-prospect-id="${prospectId}"]`);
300
+ if (stickyRow) {
301
+ if (event.type === 'mouseenter') {
302
+ stickyRow.style.backgroundColor = '#f0f4ff';
303
+ } else {
304
+ stickyRow.style.backgroundColor = '';
305
+ }
306
+ }
307
+ },
308
+
309
+ handleStickyTableHover(event) {
310
+ const prospectId = event.target.closest('tr').dataset.prospectId;
311
+ if (!prospectId) return;
312
+ const mainRow = this.mainTable.querySelector(`tr[data-prospect-id="${prospectId}"]`);
313
+ if (mainRow) {
314
+ if (event.type === 'mouseenter') {
315
+ mainRow.style.backgroundColor = '#f0f4ff';
316
+ } else {
317
+ mainRow.style.backgroundColor = '';
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ const ScrollToSelectedProspectHook = {
324
+ mounted() {
325
+ this.scrollToSelected();
326
+ },
327
+ updated() {
328
+ this.scrollToSelected();
329
+ },
330
+ scrollToSelected() {
331
+ const selectedId = this.el.getAttribute('data-selected-prospect-id');
332
+ if (!selectedId) return;
333
+ const row = this.el.querySelector(`tr[data-prospect-id=\"${selectedId}\"]`);
334
+ if (row) {
335
+ row.scrollIntoView({ behavior: 'auto', block: 'center' });
336
+ row.classList.add('prospect-row-focused');
337
+ setTimeout(() => row.classList.remove('prospect-row-focused'), 700);
338
+ }
339
+ }
340
+ };
341
+
52
342
  let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
53
- let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: {Prism: PrismHook, Sortable: SortableHook, ShowHistory: ShowHistory}})
343
+ let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: {Prism: PrismHook, Sortable: SortableHook, PhoenixCustomEvent, TableTooltip: TableTooltipHook, ProspectsTableSync: ProspectsTableSyncHook, ScrollToSelectedProspect: ScrollToSelectedProspectHook, ...live_select}})
54
344
 
55
345
  // Show progress bar on live navigation and form submits
56
346
  topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
@@ -66,4 +356,5 @@ liveSocket.connect()
66
356
  // >> liveSocket.disableLatencySim()
67
357
  window.liveSocket = liveSocket
68
358
 
69
- import './questionnaire.ts';
359
+ import './questionnaire.ts';
360
+ import './calendar.js';
package/js/calendar.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { html, LitElement } from "lit";
2
+ import { property } from 'lit/decorators.js'
3
+ import { Calendar } from "@fullcalendar/core";
4
+ import dayGridPlugin from "@fullcalendar/daygrid";
5
+ import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
6
+ import momentPlugin from "@fullcalendar/moment";
7
+
8
+ export type CalendarEvent = {
9
+ id: string;
10
+ type: "Event";
11
+ date: string;
12
+ startTime: string;
13
+ endTime: string;
14
+ }
15
+
16
+ export type Date = {
17
+ type: "Date";
18
+ text: string;
19
+ }
20
+
21
+ export type Organization = {
22
+ id: string;
23
+ type: "Organization";
24
+ text: string;
25
+ }
26
+
27
+ export class CalendarElement extends LitElement {
28
+ @property()
29
+
30
+ @property({ attribute: "date" })
31
+ date: string;
32
+
33
+ @property({ attribute: "data-events" })
34
+ events: string;
35
+
36
+ @property({ attribute: "calendar" })
37
+ calendar: Calendar;
38
+
39
+ firstUpdated() {
40
+ super.firstUpdated(null);
41
+ let calendarEl: HTMLElement = document.getElementById('full-calendar')!;
42
+ this.calendar = new Calendar(calendarEl, {
43
+ schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
44
+ plugins: [resourceTimelinePlugin, momentPlugin, dayGridPlugin],
45
+ slotMinWidth: 70,
46
+ slotMinTime: "08:00",
47
+ slotMaxTime: "20:00",
48
+ stickyHeaderDates: true,
49
+ initialView: 'dayGridWeek',
50
+ headerToolbar: {
51
+ left: 'prev,next today',
52
+ center: "title",
53
+ right: 'dayGridMonth, dayGridWeek, dayGridDay'
54
+ },
55
+ titleFormat: "dddd, MMM D, YYYY",
56
+ events: JSON.parse(this.events),
57
+ eventClick: function (info) {
58
+ info.el.dispatchEvent(
59
+ new CustomEvent("show_event", {
60
+ bubbles: true,
61
+ detail: { id: info.event.id },
62
+ }),
63
+ );
64
+ },
65
+ });
66
+ this.calendar.render();
67
+ }
68
+
69
+ render() {
70
+ return html`<div id="full-calendar"></div>`;
71
+ }
72
+
73
+ createRenderRoot() {
74
+ return this;
75
+ }
76
+
77
+ updated() {
78
+ this.calendar.gotoDate(this.date);
79
+ this.calendar.getEventSources()[0].remove();
80
+ this.calendar.addEventSource({ events: JSON.parse(this.events) });
81
+ }
82
+ }
83
+
84
+ customElements.define("pr360-calendar", CalendarElement);
@@ -150,7 +150,8 @@ export class QuestionnaireElement extends LitElement {
150
150
  <div class="questionnaire-illustration"><img src=${this.contactInfoImageUrl()}> </div>
151
151
  <div class="questionnaire--question"><h2 class="u-padding--bt">${(this.currentStep as Video).text}</h2></div>
152
152
  <vimeo-video controls src=${(this.currentStep as Video).url} class="questionnaire--video"></vimeo-video>
153
- <div data-test-id="site-phone-number"><h4>To speak to a clinical specialist, call </h4><a href="tel:${this.phoneNumber}"><h1>${this.formatPhoneNumber(this.phoneNumber)}</h1></a>.</div>
153
+ <div data-test-id="questionnaire-info"><h4>Thank you for completing the assessment. We'll be calling you within 24 hours with more information.</h4></div>
154
+ <div data-test-id="site-phone-number"><h4>If you'd like to speak with us sooner, </br> please feel free to</h4><a href="tel:${this.phoneNumber}"><h1>Call Us</h1></a></div>
154
155
  </div>
155
156
  </div>
156
157
  </div>
@@ -178,9 +179,7 @@ export class QuestionnaireElement extends LitElement {
178
179
  </div>
179
180
  <div>
180
181
  <label for="email">Email</label>
181
- <input type="email" id="email" name="email" required
182
- pattern="^[^@\s]+@[^@\s]+\\.[^@\s]+$"
183
- title="Please enter a valid email address (e.g. user@example.com)"/>
182
+ <input type="email" id="email" name="email" required />
184
183
  </div>
185
184
  <div>
186
185
  <label for="phone">Phone</label>
@@ -192,7 +191,7 @@ export class QuestionnaireElement extends LitElement {
192
191
  <label for="zip_code">Zip Code</label>
193
192
  <input type="text" id="zip_code" name="zip_code" required
194
193
  pattern="^[0-9]{5,6}$"
195
- title="Please enter a valid US zip code (5 or 6 digits)"/>
194
+ title="Please enter a valid zip code (5 or 6 digits)"/>
196
195
  </div>
197
196
  <div>
198
197
  <label for="insurance_provider">Insurance Provider</label>
@@ -290,57 +289,31 @@ export class QuestionnaireElement extends LitElement {
290
289
 
291
290
  if (this.contactInfoForm?.checkValidity()) {
292
291
  const email = this.emailInput?.value;
293
- const phone = this.phoneInput?.value;
294
- const zipCode = this.zipCodeInput?.value;
295
-
296
- // Email validation
297
- const emailRegex = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
298
- if (!emailRegex.test(email)) {
299
- alert('Please enter a valid email address');
300
- return;
301
- }
302
-
303
- // Phone validation
304
- const phoneRegex = /^\d{6,}$/;
305
- if (!phoneRegex.test(phone)) {
306
- alert('Phone number must contain only numbers and be at least 6 digits long');
307
- return;
308
- }
309
-
310
- // Zip code validation
311
- const zipCodeRegex = /^\d{5,6}$/;
312
- if (!zipCodeRegex.test(zipCode)) {
313
- alert('Zip code must be 5 or 6 digits');
314
- return;
315
- }
316
292
 
317
293
  const hsq = window['_hsq'] = window['_hsq'] || [];
318
294
  hsq.push(['identify', { email: email }]);
319
295
  hsq.push(['setPath', '/submit-contact-info']);
320
296
  hsq.push(['trackPageView']);
321
297
 
298
+ const formData = {
299
+ nodeId: this.currentStep?.id,
300
+ first_name: this.firstNameInput.value,
301
+ last_name: this.lastNameInput.value,
302
+ email: email,
303
+ zip_code: this.zipCodeInput?.value,
304
+ phone_number: this.phoneInput?.value,
305
+ insurance_provider: this.insuranceProviderSelect.value
306
+ };
307
+
308
+ window['dataLayer'] = window['dataLayer'] || [];
309
+ window['dataLayer'].push({
310
+ 'event': 'questionnaireSubmission',
311
+ 'formData': formData
312
+ });
313
+
322
314
  this.dispatchEvent(new CustomEvent('submitContactInfo', {
323
- detail: {
324
- nodeId: this.currentStep?.id,
325
- first_name: this.firstNameInput.value,
326
- last_name: this.lastNameInput.value,
327
- email: email,
328
- zip_code: zipCode,
329
- phone_number: phone,
330
- insurance_provider: this.insuranceProviderSelect.value
331
- }
315
+ detail: formData
332
316
  }));
333
317
  }
334
318
  }
335
-
336
- formatPhoneNumber(phoneNumber: string) {
337
- switch (phoneNumber.length) {
338
- case 10:
339
- return `${phoneNumber.slice(0, 3)}-${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6)}`
340
- case 11:
341
- return `${phoneNumber.slice(0, 1)}-${phoneNumber.slice(1, 4)}-${phoneNumber.slice(4, 7)}-${phoneNumber.slice(7)}`
342
- default:
343
- return phoneNumber
344
- }
345
- }
346
319
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr360-questionnaire",
3
3
  "description": "An element to render a questionnaire for PatientReach 360.",
4
- "version": "2.1.6",
4
+ "version": "2.1.10",
5
5
  "main": "dist/index.js",
6
6
  "author": {
7
7
  "email": "chris@launchscout.com",
@@ -25,9 +25,16 @@
25
25
  "phoenix": "file:../deps/phoenix",
26
26
  "phoenix_html": "file:../deps/phoenix_html",
27
27
  "phoenix_live_view": "file:../deps/phoenix_live_view",
28
+ "live_select": "file:../deps/live_select",
29
+ "phoenix-custom-event-hook": "^0.0.6",
28
30
  "phx-live-state": "^0.9.2",
29
31
  "vimeo-video-element": "^1.0.1",
30
- "mermaid-chart": "launchscout/mermaid-chart"
32
+ "mermaid-chart": "launchscout/mermaid-chart",
33
+ "@fullcalendar/core": "^6.1.8",
34
+ "@fullcalendar/daygrid": "^6.1.8",
35
+ "@fullcalendar/moment": "^6.1.11",
36
+ "@fullcalendar/resource": "^6.1.8",
37
+ "@fullcalendar/resource-timeline": "^6.1.8"
31
38
  },
32
39
  "devDependencies": {
33
40
  "@open-wc/testing": "^3.2.0",
@@ -1,15 +0,0 @@
1
- export const ShowHistory = {
2
- mounted() {
3
- const id = this.el.dataset.id
4
- const target = this.el.dataset.target
5
-
6
- this.el.addEventListener("mouseenter", () => {
7
- this.pushEventTo(target, "show_history_tooltip", { id })
8
- })
9
-
10
- this.el.addEventListener("mouseleave", () => {
11
- this.pushEventTo(target, "hide_history_tooltip", { })
12
- })
13
- },
14
- }
15
-