@hellotext/hellotext 2.1.51 → 2.2.1

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.
@@ -24,7 +24,7 @@ let Page = /*#__PURE__*/function () {
24
24
  _createClass(Page, [{
25
25
  key: "url",
26
26
  get: function () {
27
- return this._url || window.location.href;
27
+ return this._url !== null && this._url !== undefined ? this._url : window.location.href;
28
28
  }
29
29
 
30
30
  /**
@@ -80,6 +80,84 @@ let Page = /*#__PURE__*/function () {
80
80
  utm_params: this.utmParams
81
81
  };
82
82
  }
83
+
84
+ /**
85
+ * Get the current page domain (root domain for cookie sharing)
86
+ * @returns {string} The root domain with leading dot, or null if no valid URL
87
+ */
88
+ }, {
89
+ key: "domain",
90
+ get: function () {
91
+ try {
92
+ const url = this.url;
93
+ if (!url) {
94
+ return null;
95
+ }
96
+ const hostname = new URL(url).hostname;
97
+ return Page.getRootDomain(hostname);
98
+ } catch (e) {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Get the root domain from a hostname (static method, no instance needed)
105
+ * @param {string} hostname - The hostname to parse
106
+ * @returns {string} The root domain with leading dot, or null if invalid
107
+ */
108
+ }], [{
109
+ key: "getRootDomain",
110
+ value: function getRootDomain(hostname = null) {
111
+ try {
112
+ if (!hostname) {
113
+ var _window$location;
114
+ if (typeof window === 'undefined' || !((_window$location = window.location) !== null && _window$location !== void 0 && _window$location.hostname)) {
115
+ return null;
116
+ }
117
+ hostname = window.location.hostname;
118
+ }
119
+ const parts = hostname.split('.');
120
+
121
+ // Handle localhost or single-part domains
122
+ if (parts.length <= 1) {
123
+ return hostname;
124
+ }
125
+
126
+ // Handle e-commerce platform domains where store identifier must be preserved
127
+ // Examples:
128
+ // storename.myshopify.com -> .storename.myshopify.com
129
+ // storename.vtexcommercestable.com.br -> .storename.vtexcommercestable.com.br
130
+ // storename.wixsite.com -> .storename.wixsite.com
131
+ const platformSuffixes = ['myshopify.com', 'vtexcommercestable.com.br', 'myvtex.com', 'wixsite.com'];
132
+ for (const suffix of platformSuffixes) {
133
+ const suffixParts = suffix.split('.');
134
+ // Get the last N parts of the hostname (e.g., last 2 parts for 'myshopify.com')
135
+ const domainTail = parts.slice(-suffixParts.length).join('.');
136
+
137
+ // Check if the tail exactly matches the platform suffix
138
+ // and there are additional parts for the store identifier
139
+ if (domainTail === suffix && parts.length > suffixParts.length) {
140
+ // Include store identifier + platform suffix
141
+ // e.g., ['secure', 'mystore', 'myshopify', 'com'] -> '.mystore.myshopify.com'
142
+ return `.${parts.slice(-(suffixParts.length + 1)).join('.')}`;
143
+ }
144
+ }
145
+
146
+ // Simple heuristic: if TLD is 2 chars and second-level is short (<=3 chars),
147
+ // it's likely a multi-part TLD like .com.br, .co.uk
148
+ const tld = parts[parts.length - 1];
149
+ const secondLevel = parts[parts.length - 2];
150
+ if (parts.length > 2 && tld.length === 2 && secondLevel.length <= 3) {
151
+ // Multi-part TLD: take last 3 parts (e.g., store.com.br)
152
+ return `.${parts.slice(-3).join('.')}`;
153
+ }
154
+
155
+ // Regular TLD: take last 2 parts (e.g., example.com)
156
+ return `.${parts.slice(-2).join('.')}`;
157
+ } catch (e) {
158
+ return null;
159
+ }
160
+ }
83
161
  }]);
84
162
  return Page;
85
163
  }();
@@ -19,7 +19,7 @@ var Page = /*#__PURE__*/function () {
19
19
  _createClass(Page, [{
20
20
  key: "url",
21
21
  get: function get() {
22
- return this._url || window.location.href;
22
+ return this._url !== null && this._url !== undefined ? this._url : window.location.href;
23
23
  }
24
24
 
25
25
  /**
@@ -75,6 +75,85 @@ var Page = /*#__PURE__*/function () {
75
75
  utm_params: this.utmParams
76
76
  };
77
77
  }
78
+
79
+ /**
80
+ * Get the current page domain (root domain for cookie sharing)
81
+ * @returns {string} The root domain with leading dot, or null if no valid URL
82
+ */
83
+ }, {
84
+ key: "domain",
85
+ get: function get() {
86
+ try {
87
+ var url = this.url;
88
+ if (!url) {
89
+ return null;
90
+ }
91
+ var hostname = new URL(url).hostname;
92
+ return Page.getRootDomain(hostname);
93
+ } catch (e) {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Get the root domain from a hostname (static method, no instance needed)
100
+ * @param {string} hostname - The hostname to parse
101
+ * @returns {string} The root domain with leading dot, or null if invalid
102
+ */
103
+ }], [{
104
+ key: "getRootDomain",
105
+ value: function getRootDomain() {
106
+ var hostname = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null;
107
+ try {
108
+ if (!hostname) {
109
+ var _window$location;
110
+ if (typeof window === 'undefined' || !((_window$location = window.location) !== null && _window$location !== void 0 && _window$location.hostname)) {
111
+ return null;
112
+ }
113
+ hostname = window.location.hostname;
114
+ }
115
+ var parts = hostname.split('.');
116
+
117
+ // Handle localhost or single-part domains
118
+ if (parts.length <= 1) {
119
+ return hostname;
120
+ }
121
+
122
+ // Handle e-commerce platform domains where store identifier must be preserved
123
+ // Examples:
124
+ // storename.myshopify.com -> .storename.myshopify.com
125
+ // storename.vtexcommercestable.com.br -> .storename.vtexcommercestable.com.br
126
+ // storename.wixsite.com -> .storename.wixsite.com
127
+ var platformSuffixes = ['myshopify.com', 'vtexcommercestable.com.br', 'myvtex.com', 'wixsite.com'];
128
+ for (var suffix of platformSuffixes) {
129
+ var suffixParts = suffix.split('.');
130
+ // Get the last N parts of the hostname (e.g., last 2 parts for 'myshopify.com')
131
+ var domainTail = parts.slice(-suffixParts.length).join('.');
132
+
133
+ // Check if the tail exactly matches the platform suffix
134
+ // and there are additional parts for the store identifier
135
+ if (domainTail === suffix && parts.length > suffixParts.length) {
136
+ // Include store identifier + platform suffix
137
+ // e.g., ['secure', 'mystore', 'myshopify', 'com'] -> '.mystore.myshopify.com'
138
+ return ".".concat(parts.slice(-(suffixParts.length + 1)).join('.'));
139
+ }
140
+ }
141
+
142
+ // Simple heuristic: if TLD is 2 chars and second-level is short (<=3 chars),
143
+ // it's likely a multi-part TLD like .com.br, .co.uk
144
+ var tld = parts[parts.length - 1];
145
+ var secondLevel = parts[parts.length - 2];
146
+ if (parts.length > 2 && tld.length === 2 && secondLevel.length <= 3) {
147
+ // Multi-part TLD: take last 3 parts (e.g., store.com.br)
148
+ return ".".concat(parts.slice(-3).join('.'));
149
+ }
150
+
151
+ // Regular TLD: take last 2 parts (e.g., example.com)
152
+ return ".".concat(parts.slice(-2).join('.'));
153
+ } catch (e) {
154
+ return null;
155
+ }
156
+ }
78
157
  }]);
79
158
  return Page;
80
159
  }();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellotext/hellotext",
3
- "version": "2.1.51",
3
+ "version": "2.2.1",
4
4
  "description": "Hellotext JavaScript Client",
5
5
  "source": "src/index.js",
6
6
  "main": "lib/index.cjs",
@@ -0,0 +1,95 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ id: String,
6
+ }
7
+
8
+ static targets = ['carouselContainer', 'leftFade', 'rightFade', 'carouselCard']
9
+
10
+ connect() {
11
+ this.updateFades()
12
+ }
13
+
14
+ setId({ detail: id }) {
15
+ this.idValue = id
16
+ this.element.id = id
17
+ }
18
+
19
+ onScroll() {
20
+ this.updateFades()
21
+ }
22
+
23
+ quickReply({ currentTarget }) {
24
+ const card = currentTarget.closest('[data-hellotext--message-target="carouselCard"]')
25
+
26
+ this.dispatch('quickReply', {
27
+ detail: {
28
+ id: this.idValue,
29
+ product: card.dataset.id,
30
+ buttonId: currentTarget.dataset.id,
31
+ body: currentTarget.dataset.text,
32
+ cardElement: card,
33
+ },
34
+ })
35
+ }
36
+
37
+ moveToLeft() {
38
+ if (!this.hasCarouselContainerTarget) return
39
+
40
+ const scrollAmount = this.getScrollAmount()
41
+
42
+ this.carouselContainerTarget.scrollBy({
43
+ left: -scrollAmount,
44
+ behavior: 'smooth',
45
+ })
46
+ }
47
+
48
+ moveToRight() {
49
+ if (!this.hasCarouselContainerTarget) return
50
+
51
+ const scrollAmount = this.getScrollAmount()
52
+
53
+ this.carouselContainerTarget.scrollBy({
54
+ left: scrollAmount,
55
+ behavior: 'smooth',
56
+ })
57
+ }
58
+
59
+ getScrollAmount() {
60
+ // Get the actual card width from DOM
61
+ const firstCard = this.carouselContainerTarget.querySelector('.message__carousel_card')
62
+
63
+ if (!firstCard) {
64
+ return 280 // Fallback to default desktop card width
65
+ }
66
+
67
+ const cardWidth = firstCard.offsetWidth
68
+ const gap = 16 // gap-x-4 = 1rem = 16px
69
+
70
+ return cardWidth + gap
71
+ }
72
+
73
+ updateFades() {
74
+ if (!this.hasCarouselContainerTarget) return
75
+
76
+ const scrollLeft = this.carouselContainerTarget.scrollLeft
77
+ const maxScroll =
78
+ this.carouselContainerTarget.scrollWidth - this.carouselContainerTarget.clientWidth
79
+
80
+ // Show left fade if scrolled past start
81
+ if (scrollLeft > 0) {
82
+ this.leftFadeTarget.classList.remove('hidden')
83
+ } else {
84
+ this.leftFadeTarget.classList.add('hidden')
85
+ }
86
+
87
+ // Show right fade if not at end
88
+ if (scrollLeft < maxScroll - 1) {
89
+ // -1 for rounding errors
90
+ this.rightFadeTarget.classList.remove('hidden')
91
+ } else {
92
+ this.rightFadeTarget.classList.add('hidden')
93
+ }
94
+ }
95
+ }
@@ -369,6 +369,10 @@ export default class extends Controller {
369
369
  onMessageReceived(message) {
370
370
  const { id, body, attachments } = message
371
371
 
372
+ if (message.carousel) {
373
+ return this.insertCarouselMessage(message)
374
+ }
375
+
372
376
  const div = document.createElement('div')
373
377
  div.innerHTML = body
374
378
 
@@ -409,6 +413,30 @@ export default class extends Controller {
409
413
  this.unreadCounterTarget.innerText = unreadCount > 99 ? '99+' : unreadCount
410
414
  }
411
415
 
416
+ insertCarouselMessage(message) {
417
+ const html = message.html
418
+ const element = new DOMParser().parseFromString(html, 'text/html').body.firstElementChild
419
+
420
+ this.clearTypingIndicator()
421
+ this.messagesContainerTarget.appendChild(element)
422
+
423
+ element.scrollIntoView({ behavior: 'smooth' })
424
+
425
+ Hellotext.eventEmitter.dispatch('webchat:message:received', {
426
+ ...message,
427
+ body: element.querySelector('[data-body]')?.innerText || '',
428
+ })
429
+
430
+ if (this.openValue) {
431
+ this.messagesAPI.markAsSeen(message.id)
432
+ return
433
+ }
434
+
435
+ this.unreadCounterTarget.style.display = 'flex'
436
+ const unreadCount = (parseInt(this.unreadCounterTarget.innerText) || 0) + 1
437
+ this.unreadCounterTarget.innerText = unreadCount > 99 ? '99+' : unreadCount
438
+ }
439
+
412
440
  resizeInput() {
413
441
  const maxHeight = 96
414
442
 
@@ -422,6 +450,73 @@ export default class extends Controller {
422
450
  this.inputTarget.style.height = `${Math.min(scrollHeight, maxHeight)}px`
423
451
  }
424
452
 
453
+ async sendQuickReplyMessage({ detail: { id, product, buttonId, body, cardElement } }) {
454
+ const formData = new FormData()
455
+
456
+ formData.append('message[body]', body)
457
+ formData.append('message[replied_to]', id)
458
+ formData.append('message[product]', product)
459
+ formData.append('message[button]', buttonId)
460
+
461
+ formData.append('session', Hellotext.session)
462
+ formData.append('locale', Locale.toString())
463
+
464
+ const element = this.buildMessageElement()
465
+ const attachment = cardElement.querySelector('img')?.cloneNode(true)
466
+
467
+ element.querySelector('[data-body]').innerText = body
468
+
469
+ if (attachment) {
470
+ attachment.removeAttribute('width')
471
+ attachment.removeAttribute('height')
472
+
473
+ element.querySelector('[data-attachment-container]').appendChild(attachment)
474
+ }
475
+
476
+ if (this.typingIndicatorVisible && this.hasTypingIndicatorTarget) {
477
+ this.messagesContainerTarget.insertBefore(element, this.typingIndicatorTarget)
478
+ } else {
479
+ this.messagesContainerTarget.appendChild(element)
480
+ }
481
+
482
+ element.scrollIntoView({ behavior: 'smooth' })
483
+
484
+ this.broadcastChannel.postMessage({
485
+ type: 'message:sent',
486
+ element: element.outerHTML,
487
+ })
488
+
489
+ const response = await this.messagesAPI.create(formData)
490
+
491
+ if (response.failed) {
492
+ // Clear the optimistic typing indicator on failure
493
+ clearTimeout(this.optimisticTypingTimeout)
494
+
495
+ this.broadcastChannel.postMessage({
496
+ type: 'message:failed',
497
+ id: element.id,
498
+ })
499
+
500
+ return element.classList.add('failed')
501
+ }
502
+
503
+ const data = await response.json()
504
+
505
+ this.dispatch('set:id', { target: element, detail: data.id })
506
+
507
+ const message = {
508
+ id: data.id,
509
+ body: body,
510
+ attachments: attachment ? [attachment.src] : [],
511
+ replied_to: id,
512
+ product: product,
513
+ button: buttonId,
514
+ type: 'quick_reply',
515
+ }
516
+
517
+ Hellotext.eventEmitter.dispatch('webchat:message:sent', message)
518
+ }
519
+
425
520
  async sendMessage(e) {
426
521
  const formData = new FormData()
427
522
 
@@ -451,14 +546,7 @@ export default class extends Controller {
451
546
  formData.append('session', Hellotext.session)
452
547
  formData.append('locale', Locale.toString())
453
548
 
454
- const element = this.messageTemplateTarget.cloneNode(true)
455
-
456
- element.id = `hellotext--webchat--${this.idValue}--message--${Date.now()}`
457
-
458
- element.classList.add('received')
459
- element.style.removeProperty('display')
460
-
461
- element.setAttribute('data-hellotext--webchat-target', 'message')
549
+ const element = this.buildMessageElement()
462
550
 
463
551
  if (this.inputTarget.value.trim().length > 0) {
464
552
  element.querySelector('[data-body]').innerText = this.inputTarget.value
@@ -470,7 +558,7 @@ export default class extends Controller {
470
558
 
471
559
  if (attachments.length > 0) {
472
560
  attachments.forEach(attachment => {
473
- element.querySelector('[data-attachment-container]').appendChild(attachment)
561
+ element.querySelector('[data-attachment-container]').appendChild(attachment.cloneNode(true))
474
562
  })
475
563
  }
476
564
 
@@ -542,6 +630,20 @@ export default class extends Controller {
542
630
  this.attachmentContainerTarget.style.display = ''
543
631
  }
544
632
 
633
+ buildMessageElement() {
634
+ const element = this.messageTemplateTarget.cloneNode(true)
635
+
636
+ element.id = `hellotext--webchat--${this.idValue}--message--${Date.now()}`
637
+
638
+ element.classList.add('received')
639
+ element.style.removeProperty('display')
640
+
641
+ element.setAttribute('data-controller', 'hellotext--message')
642
+ element.setAttribute('data-hellotext--webchat-target', 'message')
643
+
644
+ return element
645
+ }
646
+
545
647
  openAttachment() {
546
648
  this.attachmentInputTarget.click()
547
649
  }
package/src/index.js CHANGED
@@ -2,6 +2,7 @@ import { Application } from '@hotwired/stimulus'
2
2
  import Hellotext from './hellotext'
3
3
 
4
4
  import FormController from './controllers/form_controller'
5
+ import MessageController from './controllers/message_controller'
5
6
  import WebChatEmojiController from './controllers/webchat/emoji_picker_controller'
6
7
  import WebchatController from './controllers/webchat_controller'
7
8
 
@@ -10,6 +11,7 @@ const application = Application.start()
10
11
  application.register('hellotext--form', FormController)
11
12
  application.register('hellotext--webchat', WebchatController)
12
13
  application.register('hellotext--webchat--emoji', WebChatEmojiController)
14
+ application.register('hellotext--message', MessageController)
13
15
 
14
16
  window.Hellotext = Hellotext
15
17
 
@@ -1,9 +1,18 @@
1
1
  import Hellotext from '../hellotext'
2
+ import { Page } from './page'
2
3
 
3
4
  class Cookies {
4
5
  static set(name, value) {
5
6
  if (typeof document !== 'undefined') {
6
- document.cookie = `${name}=${value}; path=/;`
7
+ const secure = window.location.protocol === 'https:' ? '; Secure' : ''
8
+ const domain = Page.getRootDomain()
9
+ const maxAge = 10 * 365 * 24 * 60 * 60 // 10 years in seconds
10
+
11
+ if (domain) {
12
+ document.cookie = `${name}=${value}; path=/${secure}; domain=${domain}; max-age=${maxAge}; SameSite=Lax`
13
+ } else {
14
+ document.cookie = `${name}=${value}; path=/${secure}; max-age=${maxAge}; SameSite=Lax`
15
+ }
7
16
  }
8
17
 
9
18
  if (name === 'hello_session') {
@@ -11,7 +11,7 @@ class Page {
11
11
  * @returns {string} The page URL
12
12
  */
13
13
  get url() {
14
- return this._url || window.location.href
14
+ return this._url !== null && this._url !== undefined ? this._url : window.location.href
15
15
  }
16
16
 
17
17
  /**
@@ -60,6 +60,88 @@ class Page {
60
60
  utm_params: this.utmParams,
61
61
  }
62
62
  }
63
+
64
+ /**
65
+ * Get the current page domain (root domain for cookie sharing)
66
+ * @returns {string} The root domain with leading dot, or null if no valid URL
67
+ */
68
+ get domain() {
69
+ try {
70
+ const url = this.url
71
+ if (!url) {
72
+ return null
73
+ }
74
+
75
+ const hostname = new URL(url).hostname
76
+ return Page.getRootDomain(hostname)
77
+ } catch (e) {
78
+ return null
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Get the root domain from a hostname (static method, no instance needed)
84
+ * @param {string} hostname - The hostname to parse
85
+ * @returns {string} The root domain with leading dot, or null if invalid
86
+ */
87
+ static getRootDomain(hostname = null) {
88
+ try {
89
+ if (!hostname) {
90
+ if (typeof window === 'undefined' || !window.location?.hostname) {
91
+ return null
92
+ }
93
+ hostname = window.location.hostname
94
+ }
95
+
96
+ const parts = hostname.split('.')
97
+
98
+ // Handle localhost or single-part domains
99
+ if (parts.length <= 1) {
100
+ return hostname
101
+ }
102
+
103
+ // Handle e-commerce platform domains where store identifier must be preserved
104
+ // Examples:
105
+ // storename.myshopify.com -> .storename.myshopify.com
106
+ // storename.vtexcommercestable.com.br -> .storename.vtexcommercestable.com.br
107
+ // storename.wixsite.com -> .storename.wixsite.com
108
+ const platformSuffixes = [
109
+ 'myshopify.com',
110
+ 'vtexcommercestable.com.br',
111
+ 'myvtex.com',
112
+ 'wixsite.com',
113
+ ]
114
+
115
+ for (const suffix of platformSuffixes) {
116
+ const suffixParts = suffix.split('.')
117
+ // Get the last N parts of the hostname (e.g., last 2 parts for 'myshopify.com')
118
+ const domainTail = parts.slice(-suffixParts.length).join('.')
119
+
120
+ // Check if the tail exactly matches the platform suffix
121
+ // and there are additional parts for the store identifier
122
+ if (domainTail === suffix && parts.length > suffixParts.length) {
123
+ // Include store identifier + platform suffix
124
+ // e.g., ['secure', 'mystore', 'myshopify', 'com'] -> '.mystore.myshopify.com'
125
+ return `.${parts.slice(-(suffixParts.length + 1)).join('.')}`
126
+ }
127
+ }
128
+
129
+ // Simple heuristic: if TLD is 2 chars and second-level is short (<=3 chars),
130
+ // it's likely a multi-part TLD like .com.br, .co.uk
131
+ const tld = parts[parts.length - 1]
132
+ const secondLevel = parts[parts.length - 2]
133
+
134
+ if (parts.length > 2 && tld.length === 2 && secondLevel.length <= 3) {
135
+ // Multi-part TLD: take last 3 parts (e.g., store.com.br)
136
+ return `.${parts.slice(-3).join('.')}`
137
+ }
138
+
139
+ // Regular TLD: take last 2 parts (e.g., example.com)
140
+ return `.${parts.slice(-2).join('.')}`
141
+ } catch (e) {
142
+ return null
143
+ }
144
+ }
63
145
  }
64
146
 
65
147
  export { Page }