@hellotext/hellotext 1.4.0 → 1.5.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.
Files changed (91) hide show
  1. package/.babelrc +6 -3
  2. package/.github/workflows/ci.yml +1 -1
  3. package/.prettierignore +2 -0
  4. package/README.md +30 -170
  5. package/__tests__/api/sessions_test.js +12 -0
  6. package/__tests__/builders/input_builder_test.js +117 -0
  7. package/__tests__/{event_emitter_test.js → core/event_test.js} +26 -6
  8. package/__tests__/hellotext_test.js +12 -0
  9. package/__tests__/models/business_test.js +26 -0
  10. package/__tests__/models/cookies_test.js +25 -0
  11. package/__tests__/models/form_collection_test.js +100 -0
  12. package/__tests__/models/form_test.js +29 -0
  13. package/__tests__/models/query_test.js +97 -0
  14. package/dist/hellotext.js +1 -1
  15. package/docs/forms.md +127 -0
  16. package/docs/tracking.md +155 -0
  17. package/lib/api/businesses.js +45 -0
  18. package/lib/api/events.js +54 -0
  19. package/lib/api/forms.js +65 -0
  20. package/lib/api/index.js +51 -0
  21. package/lib/api/response.js +57 -0
  22. package/lib/api/sessions.js +46 -0
  23. package/lib/api/submissions.js +61 -0
  24. package/lib/api.js +73 -0
  25. package/lib/builders/inputBuilder.js +97 -0
  26. package/lib/builders/input_builder.js +61 -0
  27. package/lib/builders/logo_builder.js +38 -0
  28. package/lib/builders/otp_builder.js +39 -0
  29. package/lib/controllers/form_controller.js +139 -0
  30. package/lib/controllers/otp_controller.js +114 -0
  31. package/lib/core/configuration.js +37 -0
  32. package/lib/core/event.js +73 -0
  33. package/lib/core/index.js +20 -0
  34. package/lib/errors/index.js +19 -0
  35. package/lib/errors/invalid_event.js +34 -0
  36. package/lib/errors/not_initialized_error.js +34 -0
  37. package/lib/event.js +159 -32
  38. package/lib/eventEmitter.js +139 -46
  39. package/lib/forms.js +133 -0
  40. package/lib/hellotext.js +64 -102
  41. package/lib/index.js +7 -0
  42. package/lib/locales/en.js +20 -0
  43. package/lib/locales/es.js +20 -0
  44. package/lib/locales/index.js +14 -0
  45. package/lib/models/business.js +57 -0
  46. package/lib/models/configuration.js +66 -0
  47. package/lib/models/cookies.js +34 -0
  48. package/lib/models/form.js +148 -0
  49. package/lib/models/form_collection.js +96 -0
  50. package/lib/models/index.js +40 -0
  51. package/lib/models/query.js +51 -0
  52. package/lib/models/step.js +45 -0
  53. package/lib/query.js +65 -32
  54. package/lib/response.js +82 -42
  55. package/package.json +7 -3
  56. package/src/api/businesses.js +18 -0
  57. package/src/api/events.js +24 -0
  58. package/src/api/forms.js +30 -0
  59. package/src/api/index.js +24 -0
  60. package/src/{response.js → api/response.js} +9 -4
  61. package/src/api/sessions.js +23 -0
  62. package/src/api/submissions.js +30 -0
  63. package/src/builders/input_builder.js +55 -0
  64. package/src/builders/logo_builder.js +25 -0
  65. package/src/builders/otp_builder.js +42 -0
  66. package/src/controllers/form_controller.js +105 -0
  67. package/src/controllers/otp_controller.js +77 -0
  68. package/src/core/configuration.js +16 -0
  69. package/src/core/event.js +54 -0
  70. package/src/core/index.js +2 -0
  71. package/src/errors/index.js +2 -0
  72. package/src/hellotext.js +50 -72
  73. package/src/index.js +10 -0
  74. package/src/locales/en.js +13 -0
  75. package/src/locales/es.js +13 -0
  76. package/src/locales/index.js +7 -0
  77. package/src/models/business.js +41 -0
  78. package/src/models/cookies.js +16 -0
  79. package/src/models/form.js +133 -0
  80. package/src/models/form_collection.js +81 -0
  81. package/src/models/index.js +5 -0
  82. package/src/models/query.js +33 -0
  83. package/styles/index.css +32 -0
  84. package/webpack.config.js +15 -9
  85. package/__tests__/event_test.js +0 -21
  86. package/__tests__/query_test.js +0 -13
  87. package/src/event.js +0 -15
  88. package/src/eventEmitter.js +0 -28
  89. package/src/query.js +0 -21
  90. /package/src/errors/{invalidEvent.js → invalid_event.js} +0 -0
  91. /package/src/errors/{notInitializedError.js → not_initialized_error.js} +0 -0
@@ -0,0 +1,41 @@
1
+ import API from '../api/businesses'
2
+
3
+ import locales from '../locales'
4
+
5
+ class Business {
6
+ constructor(id) {
7
+ this.id = id
8
+ this.data = {}
9
+ this.fetchPublicData()
10
+ }
11
+
12
+ get subscription() {
13
+ return this.data.subscription
14
+ }
15
+
16
+ get country() {
17
+ return this.data.country
18
+ }
19
+
20
+ get enabledWhitelist() {
21
+ return this.data.whitelist !== 'disabled'
22
+ }
23
+
24
+ get locale() {
25
+ return locales[this.data.locale]
26
+ }
27
+
28
+ get features() {
29
+ return this.data.features
30
+ }
31
+
32
+ // private
33
+
34
+ fetchPublicData() {
35
+ API.get(this.id)
36
+ .then(response => response.json())
37
+ .then(data => (this.data = data))
38
+ }
39
+ }
40
+
41
+ export { Business }
@@ -0,0 +1,16 @@
1
+ import Hellotext from '../hellotext'
2
+
3
+ class Cookies {
4
+ static set(name, value) {
5
+ document.cookie = `${name}=${value}`
6
+ Hellotext.eventEmitter.dispatch('session-set', value)
7
+
8
+ return value
9
+ }
10
+
11
+ static get(name) {
12
+ return document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop()
13
+ }
14
+ }
15
+
16
+ export { Cookies }
@@ -0,0 +1,133 @@
1
+ import Hellotext from '../hellotext'
2
+
3
+ import { InputBuilder } from '../builders/input_builder'
4
+ import { LogoBuilder } from '../builders/logo_builder'
5
+
6
+ class Form {
7
+ constructor(data, element = null) {
8
+ this.data = data
9
+ this.element =
10
+ element ||
11
+ document.querySelector(`[data-hello-form="${this.id}"]`) ||
12
+ document.createElement('form')
13
+ }
14
+
15
+ async mount() {
16
+ const firstStep = this.data.steps[0]
17
+
18
+ this.buildHeader(firstStep.header)
19
+ this.buildInputs(firstStep.inputs)
20
+ this.buildButton(firstStep.button)
21
+ this.buildFooter(firstStep.footer)
22
+
23
+ this.elementAttributes.forEach(attribute => {
24
+ this.element.setAttribute(attribute.name, attribute.value)
25
+ })
26
+
27
+ if (!document.contains(this.element)) {
28
+ document.body.appendChild(this.element)
29
+ }
30
+
31
+ if(!Hellotext.business.features.white_label) {
32
+ this.element.prepend(LogoBuilder.build())
33
+ }
34
+ }
35
+
36
+ buildHeader(header) {
37
+ const headerElement = this.#findOrCreateComponent('[data-form-header]', 'header')
38
+ headerElement.innerHTML = header.content
39
+
40
+ if (this.element.querySelector('[data-form-header]')) {
41
+ this.element.querySelector('[data-form-header]').replaceWith(headerElement)
42
+ } else {
43
+ this.element.prepend(headerElement)
44
+ }
45
+ }
46
+
47
+ buildInputs(inputs) {
48
+ const inputsContainerElement = this.#findOrCreateComponent('[data-form-inputs]', 'main')
49
+ const inputElements = inputs.map(input => InputBuilder.build(input))
50
+
51
+ inputElements.forEach(inputElement => inputsContainerElement.appendChild(inputElement))
52
+
53
+ if (this.element.querySelector('[data-form-inputs]')) {
54
+ this.element.querySelector('[data-form-inputs]').replaceWith(inputsContainerElement)
55
+ } else {
56
+ this.element
57
+ .querySelector('[data-form-header]')
58
+ .insertAdjacentHTML('afterend', inputsContainerElement.outerHTML)
59
+ }
60
+ }
61
+
62
+ buildButton(button) {
63
+ const buttonElement = this.#findOrCreateComponent('[data-form-button]', 'button')
64
+ buttonElement.innerText = button.text
65
+
66
+ buttonElement.setAttribute('data-action', 'click->hellotext--form#submit')
67
+ buttonElement.setAttribute('data-hellotext--form-target', 'button')
68
+
69
+ if (this.element.querySelector('[data-form-button]')) {
70
+ this.element.querySelector('[data-form-button]').replaceWith(buttonElement)
71
+ } else {
72
+ this.element
73
+ .querySelector('[data-form-inputs]')
74
+ .insertAdjacentHTML('afterend', buttonElement.outerHTML)
75
+ }
76
+ }
77
+
78
+ buildFooter(footer) {
79
+ const element = this.#findOrCreateComponent('[data-form-footer]', 'footer')
80
+ element.innerHTML = footer.content
81
+
82
+ if (this.element.querySelector('[data-form-footer]')) {
83
+ this.element.querySelector('[data-form-footer]').replaceWith(element)
84
+ } else {
85
+ this.element.appendChild(element)
86
+ }
87
+ }
88
+
89
+ markAsCompleted(data) {
90
+ localStorage.setItem(`hello-form-${this.id}`, 'completed')
91
+ Hellotext.eventEmitter.dispatch('form:completed', { id: this.id, ...data })
92
+ }
93
+
94
+ get id() {
95
+ return this.data.id
96
+ }
97
+
98
+ get elementAttributes() {
99
+ return [
100
+ {
101
+ name: 'data-controller',
102
+ value: 'hellotext--form',
103
+ },
104
+ {
105
+ name: 'data-hello-form',
106
+ value: this.id,
107
+ },
108
+ {
109
+ name: 'data-hellotext--form-data-value',
110
+ value: JSON.stringify(this.data),
111
+ },
112
+ {
113
+ name: 'data-action',
114
+ value: 'hellotext--otp:verified->hellotext--form#completed',
115
+ },
116
+ ]
117
+ }
118
+
119
+ #findOrCreateComponent(selector, tag) {
120
+ const existingElement = this.element.querySelector(selector)
121
+
122
+ if (existingElement) {
123
+ return existingElement.cloneNode(true)
124
+ }
125
+
126
+ const createdElement = document.createElement(tag)
127
+ createdElement.setAttribute(selector.replace('[', '').replace(']', ''), '')
128
+
129
+ return createdElement
130
+ }
131
+ }
132
+
133
+ export { Form }
@@ -0,0 +1,81 @@
1
+ import Hellotext from '../hellotext.js'
2
+ import API from '../api'
3
+
4
+ import { Form } from './form'
5
+
6
+ import { NotInitializedError } from '../errors'
7
+
8
+ class FormCollection {
9
+ constructor() {
10
+ this.forms = []
11
+
12
+ this.includes = this.includes.bind(this)
13
+ this.excludes = this.excludes.bind(this)
14
+
15
+ this.add = this.add.bind(this)
16
+ }
17
+
18
+ collect() {
19
+ if(Hellotext.notInitialized) {
20
+ throw new NotInitializedError()
21
+ }
22
+
23
+ const formsIdsToFetch = this.#formIdsToFetch
24
+ if (formsIdsToFetch.length === 0) return
25
+
26
+ const promises = formsIdsToFetch.map(id => {
27
+ return API.forms.get(id).then(response => response.json())
28
+ })
29
+
30
+ if (!Hellotext.business.enabledWhitelist) {
31
+ console.warn(
32
+ 'No whitelist has been configured. It is advised to whitelist the domain to avoid bots from submitting forms.',
33
+ )
34
+ }
35
+
36
+ Promise.all(promises)
37
+ .then(forms => forms.forEach(this.add))
38
+ .then(() => Hellotext.eventEmitter.dispatch('forms:collected', this))
39
+ }
40
+
41
+ forEach(callback) {
42
+ this.forms.forEach(callback)
43
+ }
44
+
45
+ map(callback) {
46
+ return this.forms.map(callback)
47
+ }
48
+
49
+ add(data) {
50
+ if (this.includes(data.id)) return
51
+ this.forms.push(new Form(data))
52
+ }
53
+
54
+ getById(id) {
55
+ return this.forms.find(form => form.id === id)
56
+ }
57
+
58
+ getByIndex(index) {
59
+ return this.forms[index]
60
+ }
61
+
62
+ includes(formId) {
63
+ return this.forms.some(form => form.id === formId)
64
+ }
65
+
66
+ excludes(id) {
67
+ return !this.includes(id)
68
+ }
69
+
70
+ get length() {
71
+ return this.forms.length
72
+ }
73
+
74
+ get #formIdsToFetch() {
75
+ return Array.from(document.querySelectorAll('[data-hello-form]'))
76
+ .map(form => form.dataset.helloForm)
77
+ .filter(this.excludes)
78
+ }
79
+ }
80
+
81
+ export { FormCollection }
@@ -0,0 +1,5 @@
1
+ export { Business } from './business'
2
+ export { Form } from './form'
3
+ export { Query } from './query'
4
+ export { Cookies } from './cookies'
5
+ export { FormCollection } from './form_collection'
@@ -0,0 +1,33 @@
1
+ import { Cookies } from './cookies'
2
+
3
+ class Query {
4
+ static get inPreviewMode() {
5
+ return new this().inPreviewMode
6
+ }
7
+
8
+ constructor() {
9
+ this.urlSearchParams = new URLSearchParams(window.location.search)
10
+ }
11
+
12
+ get(param) {
13
+ return this.urlSearchParams.get(this.toHellotextParam(param))
14
+ }
15
+
16
+ has(param) {
17
+ return this.urlSearchParams.has(this.toHellotextParam(param))
18
+ }
19
+
20
+ get inPreviewMode() {
21
+ return this.has('preview')
22
+ }
23
+
24
+ get session() {
25
+ return this.get('session') || Cookies.get('hello_session')
26
+ }
27
+
28
+ toHellotextParam(param) {
29
+ return `hello_${param}`
30
+ }
31
+ }
32
+
33
+ export { Query }
@@ -0,0 +1,32 @@
1
+ form[data-hello-form] {
2
+ position: relative;
3
+
4
+ article {
5
+ [data-error-container] {
6
+ display: none;
7
+ }
8
+
9
+ &:has(input:invalid) {
10
+ [data-error-container] {
11
+ display: block;
12
+ }
13
+ }
14
+ }
15
+
16
+ [data-logo-container] {
17
+ display: flex;
18
+ justify-content: center;
19
+ align-items: flex-end;
20
+ position: absolute;
21
+ right: 1rem;
22
+ bottom: 1rem;
23
+
24
+ small {
25
+ margin: 0 .3rem;
26
+ }
27
+
28
+ [data-hello-brand] {
29
+ width: 4rem;
30
+ }
31
+ }
32
+ }
package/webpack.config.js CHANGED
@@ -1,18 +1,24 @@
1
1
  module.exports = {
2
2
  mode: 'production',
3
- entry: ['whatwg-fetch', './lib/hellotext.js'],
3
+ entry: ['whatwg-fetch', '@hotwired/stimulus', './lib/index.js'],
4
4
  output: {
5
- filename: 'hellotext.js'
5
+ filename: 'hellotext.js',
6
6
  },
7
7
  module: {
8
8
  rules: [
9
9
  {
10
10
  test: /\.m?js$/,
11
11
  exclude: [/node_modules/],
12
- use: [{
13
- loader: 'babel-loader',
14
- }]
15
- }
16
- ]
17
- }
18
- };
12
+ use: [
13
+ {
14
+ loader: 'babel-loader',
15
+ },
16
+ ],
17
+ },
18
+ {
19
+ test: /\.css$/i,
20
+ use: ['style-loader', 'css-loader'],
21
+ },
22
+ ],
23
+ },
24
+ }
@@ -1,21 +0,0 @@
1
- import Event from "../src/event"
2
-
3
- describe(".valid", function () {
4
- it("is true when event name is a valid defined name", () => {
5
- expect(Event.valid("session-set")).toEqual(true)
6
- });
7
-
8
- it("is false when event name is not defined", () => {
9
- expect(Event.valid("undefined-event")).toEqual(false)
10
- });
11
- });
12
-
13
- describe(".invalid", () => {
14
- it("is true when event name is not defined", () => {
15
- expect(Event.invalid("undefined-event")).toEqual(true)
16
- });
17
-
18
- it("is false when event name is a valid defined name", () => {
19
- expect(Event.invalid("session-set")).toEqual(false)
20
- });
21
- });
@@ -1,13 +0,0 @@
1
- /**
2
- * @jest-environment jsdom
3
- */
4
-
5
- import Query from "../src/query";
6
-
7
- describe("#toHellotextParameter", () => {
8
- it("prefixes the argument with hello_", () => {
9
- const query = new Query()
10
-
11
- expect(query.toHellotextParam("preview")).toEqual("hello_preview")
12
- });
13
- })
package/src/event.js DELETED
@@ -1,15 +0,0 @@
1
- export default class Event {
2
- static events = ["session-set"]
3
-
4
- static valid(name) {
5
- return Event.exists(name)
6
- }
7
-
8
- static invalid(name) {
9
- return !this.valid(name)
10
- }
11
-
12
- static exists(name) {
13
- return this.events.find((eventName) => eventName === name) !== undefined
14
- }
15
- }
@@ -1,28 +0,0 @@
1
- export default class EventEmitter {
2
- constructor() {
3
- this.subscribers = {}
4
- }
5
-
6
- addSubscriber(eventName, callback) {
7
- this.subscribers = {
8
- ...this.subscribers,
9
- [eventName]: this.subscribers[eventName] ? [...this.subscribers[eventName], callback] : [callback]
10
- }
11
- }
12
-
13
- removeSubscriber(eventName, callback) {
14
- if(this.subscribers[eventName]) {
15
- this.subscribers[eventName] = this.subscribers[eventName].filter((cb) => cb !== callback)
16
- }
17
- }
18
-
19
- emit(eventName, data) {
20
- this.subscribers[eventName].forEach((subscriber) => {
21
- subscriber(data)
22
- })
23
- }
24
-
25
- get listeners() {
26
- return Object.keys(this.subscribers).length !== 0
27
- }
28
- }
package/src/query.js DELETED
@@ -1,21 +0,0 @@
1
- export default class Query {
2
- constructor(urlQueries) {
3
- this.urlSearchParams = new URLSearchParams(urlQueries)
4
- }
5
-
6
- get(param) {
7
- return this.urlSearchParams.get(
8
- this.toHellotextParam(param)
9
- )
10
- }
11
-
12
- has(param) {
13
- return this.urlSearchParams.has(
14
- this.toHellotextParam(param)
15
- )
16
- }
17
-
18
- toHellotextParam(param) {
19
- return `hello_${param}`
20
- }
21
- }