@hellotext/hellotext 1.1.0 → 1.1.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.
package/.babelrc ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "presets": ["@babel/preset-env"]
3
+ }
@@ -0,0 +1,24 @@
1
+ name: Tests
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - '*'
7
+ push:
8
+ branches:
9
+ - main
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ node: ['14', '16']
17
+ name: Node ${{ matrix.node }}
18
+ steps:
19
+ - uses: actions/checkout@v2
20
+ - uses: actions/setup-node@v2
21
+ with:
22
+ node-version: ${{ matrix.node }}
23
+ - run: npm install
24
+ - run: npm run test
package/README.md CHANGED
@@ -43,6 +43,7 @@ Hellotext.track("page.viewed");
43
43
  ```
44
44
 
45
45
  In the example above only the name of the action is required.
46
+
46
47
  The library takes care of handling the `url` parameter with the current URL automatically and is not required to specify it explicitly.
47
48
  If you want to provide another url, you can pass a `url` key in the params object when tracking an event.
48
49
 
@@ -56,7 +57,19 @@ The `track` method returns a Promise that can be `await`ed using the async/await
56
57
 
57
58
  ```javascript
58
59
  const response = await Hellotext.track("page.viewed");
59
- // {status: "received", success: true}
60
+ ```
61
+
62
+ The return of the `Hellotext.track` method is an instance of a `Response` object that ships with the package. You can check the status of the response via methods, like
63
+
64
+ ```javascript
65
+ if(response.failed) {
66
+ console.log("failed because", response.data)
67
+ }
68
+
69
+ if(response.succeeded) {
70
+ console.log("success")
71
+ console.log(response.data) // { status: "received" }
72
+ }
60
73
  ```
61
74
 
62
75
  The parameters passed to the action must be a valid set of parameters as described in
@@ -66,6 +79,13 @@ Failing to provide valid set of parameters will result in an error object being
66
79
 
67
80
  ```javascript
68
81
  const response = await Hellotext.track("app.installed", { app_attributes: { name: "My App" }})
82
+
83
+ console.log(response.data)
84
+ ```
85
+
86
+ yields
87
+
88
+ ```javascript
69
89
  {
70
90
  errors: [
71
91
  {
@@ -73,15 +93,15 @@ const response = await Hellotext.track("app.installed", { app_attributes: { name
73
93
  parameter: 'name',
74
94
  description: 'The value must be unique and it is already present in another object of the same type.'
75
95
  },
76
- ],
77
- success: false
96
+ ]
78
97
  }
79
98
  ```
80
99
 
81
- You can use `success` key present in the response object to run code conditionally.
82
100
  For a complete list of errors types. See [Error Types](https://www.hellotext.com/api#errors)
83
101
 
84
102
  Generally, most actions also require an associated object. These can be of type [`app`](https://www.hellotext.com/api#apps), [`coupon`](https://www.hellotext.com/api#coupons), [`form`](https://www.hellotext.com/api#forms), [`order`](https://www.hellotext.com/api#orders), [`product`](https://www.hellotext.com/api#products) and [`refund`](https://www.hellotext.com/api#refunds).
103
+ Aside from [Custom Actions](https://www.hellotext.com/api#create_an_action), which don't require the trackable to be present.
104
+
85
105
 
86
106
  You can create the associated object directly by defining its attributes in a hash:
87
107
 
@@ -157,6 +177,27 @@ Hellotext.track("product.purchased", {
157
177
  | **tracked_at** | Original date when the event happened. This is useful if you want to record an event that happened in the past. If no value is provided its value will be the same from `created_at`. | epoch | `null`
158
178
 
159
179
 
180
+ ## Events
181
+
182
+ This library emits events that you can listen to and perform specific action when the event happens.
183
+ Think of it like `addEventListener` for HTML elements. You can listen for events, and remove events as well.
184
+
185
+ To listen to an event, you can call the `on` method, like so
186
+
187
+ ```javascript
188
+ Hellotext.on(eventName, callback)
189
+ ```
190
+
191
+ To disconnect an event listener, you can call `disconnect`
192
+
193
+ ```javascript
194
+ Hellotext.disconnect(eventName, callback)
195
+ ```
196
+
197
+ ### List of events
198
+
199
+ - `session-set`: This event is fired when the session value for `Hellotext.session` is set. Either through an API request, or if the session was found in the cookie.
200
+
160
201
  ## Understanding Sessions
161
202
 
162
203
  The library looks for a session identifier present on the `hellotext_session` query parameter. If the session is not present as a cookie neither it will create a new random session identifier.
@@ -172,8 +213,24 @@ It is possible to obtain the current session by simply calling `Hellotext.sessio
172
213
  await Hellotext.session
173
214
  // Returns bBJn9vR15yPaYkWmR2QK0jopMeNxrA6l
174
215
  ```
175
- Calling the `Hellotext.session` for the first time will send a request to Hellotext to create a session,
176
- then the class stores the value in the `hello_session` cookie. Subsequent calls would not result in requests made to the server,
177
- because the class stores the value.
216
+
217
+ If the session has not been set yet, the result returned will be `undefined`.
218
+ You can check whether the session has been set or not by calling `Hellotext.isInitialized`.
219
+
220
+ ```javascript
221
+ if(Hellotext.isInitialized) {
222
+ console.log("session is present")
223
+ } else {
224
+ console.log("session has not been set")
225
+ }
226
+ ```
227
+
228
+ Moreover, you can hook in and listen for the session being set, such that when it's set, you're notified about the change, like so
229
+
230
+ ```javascript
231
+ Hellotext.on("session-set", (session) => {
232
+ console.log("session is: ", session)
233
+ })
234
+ ```
178
235
 
179
236
  You may want to store the session on your backend when customers are unidentified so you can later [attach it to a profile](https://www.hellotext.com/api#attach_session) when it becomes known.
@@ -0,0 +1,36 @@
1
+ import EventEmitter from "../lib/eventEmitter"
2
+
3
+ describe("#addSubscriber", function () {
4
+ const instance = new EventEmitter()
5
+
6
+ it("adds the callback to the list of subscribers for an event", () => {
7
+ const callback = (session) => {}
8
+ instance.addSubscriber("session-set", callback)
9
+ expect(instance.subscribers["session-set"].length).toEqual(1)
10
+ });
11
+ });
12
+
13
+ describe("#removeSubscriber", function () {
14
+ const instance = new EventEmitter()
15
+
16
+ it("removes the callback from the list of subscribers for an event", () => {
17
+ const callback = (session) => {}
18
+ instance.addSubscriber("session-set", callback)
19
+ instance.removeSubscriber("session-set", callback)
20
+
21
+ expect(instance.subscribers["session-set"].length).toEqual(0)
22
+ });
23
+ });
24
+
25
+ describe("#emit", () => {
26
+ const instance = new EventEmitter()
27
+
28
+ it("notifies the listeners for an event", () => {
29
+ const callback = jest.fn()
30
+
31
+ instance.addSubscriber("session-set", callback)
32
+ instance.emit("session-set", "session_payload")
33
+
34
+ expect(callback).toHaveBeenCalledTimes(1)
35
+ });
36
+ });
@@ -0,0 +1,21 @@
1
+ import Event from "../lib/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
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import Hellotext from "../lib/hellotext";
6
+
7
+ const getCookieValue = name => document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop()
8
+
9
+ describe("when trying to call methods before initializing the class", () => {
10
+ it("raises an error when Hellotext.session is called", () => {
11
+ expect(() => Hellotext.session).toThrowError()
12
+ });
13
+
14
+ it("raises an error when Hellotext.track is called", () => {
15
+ expect(Hellotext.track("page.viewed")).rejects.toThrowError()
16
+ });
17
+ })
18
+
19
+ describe("when the class is initialized successfully", () => {
20
+ const business_id = "xy76ks"
21
+
22
+ describe("when hello_session is present in the query params", () => {
23
+ beforeAll(() => {
24
+ const windowMock = {
25
+ location: { search: "?hello_session=session" },
26
+ }
27
+
28
+ jest.spyOn(global, 'window', 'get').mockImplementation(() => windowMock)
29
+ Hellotext.initialize(business_id)
30
+ })
31
+
32
+ it("sets the cookie as value of the query parameter", () => {
33
+ expect(getCookieValue("hello_session")).toEqual("session")
34
+ });
35
+
36
+ it("returns the value when Hellotext.session is called", () => {
37
+ expect(Hellotext.session).toEqual("session")
38
+ });
39
+
40
+ describe("when tracking events", () => {
41
+ it("success attribute is true when response from the server is received successfully", async () => {
42
+ global.fetch = jest.fn().mockResolvedValue({
43
+ json: jest.fn().mockResolvedValue({received: "success"}),
44
+ status: 200
45
+ })
46
+
47
+ const response = await Hellotext.track("page.viewed")
48
+
49
+ expect(response.succeeded).toEqual(true)
50
+ });
51
+
52
+ it("success attribute is false when response from the server is rejected", async () => {
53
+ global.fetch = jest.fn().mockResolvedValue({
54
+ json: jest.fn().mockResolvedValue({}),
55
+ status: 422
56
+ })
57
+
58
+ const response = await Hellotext.track("page.viewed")
59
+
60
+ expect(response.failed).toEqual(true)
61
+ });
62
+ });
63
+ });
64
+
65
+ describe("when hello_session is not present in the query params", () => {
66
+ const business_id = "xy76ks"
67
+
68
+ beforeAll(() => {
69
+ const windowMock = {location: { search: "" },}
70
+ jest.spyOn(global, 'window', 'get').mockImplementation(() => windowMock)
71
+ })
72
+
73
+ it("mints a new session token and sets the cookie as well", () => {
74
+ global.fetch = jest.fn().mockResolvedValue({
75
+ json: jest.fn().mockResolvedValue({id: "generated_token"}),
76
+ status: 200
77
+ })
78
+
79
+ Hellotext.initialize(business_id)
80
+
81
+ setTimeout(() => {
82
+ expect(getCookieValue("hello_session")).toEqual("generated_token")
83
+ expect(Hellotext.session).toEqual("generated_token")
84
+ }, 1000)
85
+ });
86
+ });
87
+ });
88
+
89
+ describe(".isInitialized", () => {
90
+ describe("when session is set", () => {
91
+ beforeAll(() => {
92
+ const windowMock = {location: { search: "?hello_session=session" }}
93
+ jest.spyOn(global, 'window', 'get').mockImplementation(() => windowMock)
94
+ Hellotext.initialize("123")
95
+ })
96
+
97
+ it("is true", () => {
98
+ expect(Hellotext.isInitialized).toEqual(true)
99
+ });
100
+ });
101
+ });
102
+
103
+ describe(".on", () => {
104
+ const business_id = "xy76ks"
105
+
106
+ beforeAll(() => {
107
+ const windowMock = {location: { search: "" },}
108
+ jest.spyOn(global, 'window', 'get').mockImplementation(() => windowMock)
109
+ })
110
+
111
+ it("registers a callback that is called when the session is set", function () {
112
+ global.fetch = jest.fn().mockResolvedValue({
113
+ json: jest.fn().mockResolvedValue({id: "generated_token"}),
114
+ status: 200
115
+ })
116
+
117
+ const callback = jest.fn()
118
+
119
+ Hellotext.on("session-set", callback)
120
+ Hellotext.initialize(business_id)
121
+
122
+ expect(callback).toHaveBeenCalledTimes(1)
123
+ });
124
+
125
+ it("throws an error when event is invalid", () => {
126
+ expect(
127
+ () => Hellotext.on("undefined-event", () => {})
128
+ ).toThrowError()
129
+ });
130
+ });
131
+
132
+ describe(".disconnect", () => {
133
+ const business_id = "xy76ks"
134
+
135
+ beforeAll(() => {
136
+ const windowMock = {location: { search: "?hello_session=123" },}
137
+ jest.spyOn(global, 'window', 'get').mockImplementation(() => windowMock)
138
+
139
+ Hellotext.initialize(123)
140
+ })
141
+
142
+ it("throws an error when event is invalid", () => {
143
+ expect(
144
+ () => Hellotext.disconnect("undefined-event", () => {})
145
+ ).toThrowError()
146
+ });
147
+
148
+ it("removes the callback from the subscribers and will not be notified again", () => {
149
+ const callback = jest.fn()
150
+
151
+ Hellotext.on("session-set", callback)
152
+ Hellotext.disconnect("session-set", callback)
153
+
154
+ expect(callback).toHaveBeenCalledTimes(0)
155
+ });
156
+ })
@@ -0,0 +1,8 @@
1
+ class InvalidEvent extends Error {
2
+ constructor(event) {
3
+ super(`${event} is not valid. Please provide a valid event name`)
4
+ this.name = 'InvalidEvent'
5
+ }
6
+ }
7
+
8
+ export { InvalidEvent }
package/lib/event.js ADDED
@@ -0,0 +1,15 @@
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
+ }
@@ -0,0 +1,28 @@
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/lib/hellotext.js CHANGED
@@ -1,104 +1,145 @@
1
+ import Event from "./event"
2
+ import EventEmitter from "./eventEmitter"
3
+ import Response from "./response";
4
+
1
5
  import { NotInitializedError } from './errors/notInitializedError'
6
+ import { InvalidEvent } from "./errors/invalidEvent"
2
7
 
3
8
  const apiUrl = 'https://api.hellotext.com/v1/'
4
9
 
5
10
  class Hellotext {
6
- static #_session
7
- static #business_id
11
+ static #session
12
+ static #business
13
+ static #eventEmitter = new EventEmitter()
14
+
8
15
  /**
9
16
  * initialize the module.
17
+ * @param business public business id
10
18
  */
11
- static initialize(business_id) {
12
- this.#business_id = business_id
19
+ static initialize(business) {
20
+ this.#business = business
13
21
 
14
22
  const urlSearchParams = new URLSearchParams(window.location.search)
15
- const session = urlSearchParams.get('hello_session') || getCookieValue('hello_session')
23
+ const session = urlSearchParams.get('hello_session') || this.#cookie
16
24
 
17
25
  if (session && session !== "undefined" && session !== "null") {
18
- this.#_session = session
26
+ this.#session = session
19
27
  this.#setSessionCookie()
28
+ } else {
29
+ this.#mintAnonymousSession()
30
+ .then(response => {
31
+ this.#session = response.id
32
+ this.#setSessionCookie()
33
+ })
20
34
  }
21
35
  }
22
36
 
23
37
  /**
38
+ * Tracks an action that has happened on the page
24
39
  *
25
40
  * @param { String } action a valid action name
26
41
  * @param { Object } params
27
42
  * @returns {Promise}
28
43
  */
29
- static async track(action, params) {
30
- if (this.notInitialized) { throw new NotInitializedError() }
44
+ static async track(action, params = {}) {
45
+ if (this.#notInitialized) { throw new NotInitializedError() }
31
46
 
32
47
  const response = await fetch(apiUrl + 'track/events', {
33
- headers: this.headers,
48
+ headers: this.#headers,
34
49
  method: 'post',
35
50
  body: JSON.stringify({
36
- session: await this.session,
51
+ session: this.session,
37
52
  action,
38
53
  ...params,
39
54
  url: (params && params.url) || window.location.href
40
55
  }),
41
56
  })
42
57
 
43
- const body = await response.json()
44
- body.success = response.status === 200
58
+ return new Response(response.status === 200, await response.json)
59
+ }
60
+
61
+ /**
62
+ * Registers an event listener
63
+ * @param event the name of the event to listen to
64
+ * @param callback the callback. This method will be called with the payload
65
+ */
66
+ static on(event, callback) {
67
+ if(Event.invalid(event)) { throw new InvalidEvent(event) }
45
68
 
46
- return body
69
+ this.#eventEmitter.addSubscriber(event, callback)
70
+ }
71
+
72
+ /**
73
+ * Removes an event listener
74
+ * @param event the name of the event to remove
75
+ * @param callback the callback to remove
76
+ */
77
+ static disconnect(event, callback) {
78
+ if(Event.invalid(event)) { throw new InvalidEvent(event) }
79
+
80
+ this.#eventEmitter.removeSubscriber(event, callback)
47
81
  }
48
82
 
49
83
  /**
50
84
  *
51
- * @returns {Promise<any>|String}
85
+ * @returns {String}
52
86
  */
53
87
  static get session() {
54
- if (this.notInitialized) { throw new NotInitializedError() }
88
+ if (this.#notInitialized) { throw new NotInitializedError() }
55
89
 
56
- if (this.#_session) return this.#_session
90
+ return this.#session
91
+ }
57
92
 
58
- return this.mintAnonymousSession()
59
- .then(response => {
60
- this.#_session = response.id
61
- this.#setSessionCookie()
62
- })
63
- .then(() => this.#_session)
93
+ /**
94
+ * Determines if the session is set or not
95
+ * @returns {boolean}
96
+ */
97
+ static get isInitialized() {
98
+ return this.#session !== undefined
64
99
  }
65
100
 
66
101
  // private
67
102
 
68
- static async mintAnonymousSession() {
69
- if (this.notInitialized) { throw new NotInitializedError() }
103
+ static get #notInitialized() {
104
+ return this.#business === undefined
105
+ }
106
+
107
+ static async #mintAnonymousSession() {
108
+ if (this.#notInitialized) { throw new NotInitializedError() }
70
109
 
71
110
  const trackingUrl = apiUrl + 'track/sessions'
72
111
 
73
112
  this.mintingPromise = await fetch(trackingUrl, {
74
113
  method: 'post',
75
- headers: { Authorization: `Bearer ${this.#business_id}` },
114
+ headers: { Authorization: `Bearer ${this.#business}` },
76
115
  })
77
116
 
78
117
  return this.mintingPromise.json()
79
118
  }
80
119
 
81
- static get headers() {
82
- if (this.notInitialized) { throw new NotInitializedError() }
120
+ static get #headers() {
121
+ if (this.#notInitialized) { throw new NotInitializedError() }
83
122
 
84
123
  return {
85
- Authorization: `Bearer ${this.#business_id}`,
124
+ Authorization: `Bearer ${this.#business}`,
86
125
  Accept: 'application.json',
87
126
  'Content-Type': 'application/json',
88
127
  }
89
128
  }
90
129
 
91
130
  static #setSessionCookie() {
92
- if (this.notInitialized) { throw new NotInitializedError() }
131
+ if (this.#notInitialized) { throw new NotInitializedError() }
132
+
133
+ if(this.#eventEmitter.listeners) {
134
+ this.#eventEmitter.emit("session-set", this.#session)
135
+ }
93
136
 
94
- document.cookie = `hello_session=${this.#_session}`
137
+ document.cookie = `hello_session=${this.#session}`
95
138
  }
96
139
 
97
- static get notInitialized() {
98
- return this.#business_id === undefined
140
+ static #cookie() {
141
+ return document.cookie.match('(^|;)\\s*' + 'hello_session' + '\\s*=\\s*([^;]+)')?.pop()
99
142
  }
100
143
  }
101
144
 
102
- const getCookieValue = name => document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop()
103
-
104
145
  export default Hellotext
@@ -0,0 +1,21 @@
1
+ export default class Response {
2
+ #success
3
+ #response
4
+
5
+ constructor(success, response) {
6
+ this.#success = success
7
+ this.#response = response
8
+ }
9
+
10
+ data() {
11
+ return this.#response
12
+ }
13
+
14
+ get failed() {
15
+ return this.#success === false
16
+ }
17
+
18
+ get succeeded() {
19
+ return this.#success === true
20
+ }
21
+ }
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@hellotext/hellotext",
3
- "version": "1.1.0",
4
- "description": "Hellotext javascript client",
3
+ "version": "1.1.1",
4
+ "description": "Hellotext JavaScript Client",
5
5
  "main": "index.js",
6
6
  "author": "Hellotext",
7
7
  "license": "MIT",
8
8
  "homepage": "https://github.com/hellotext/hellotext.js",
9
9
  "devDependencies": {
10
+ "@babel/preset-env": "^7.20.2",
11
+ "@testing-library/jest-dom": "^5.16.5",
12
+ "jest": "^29.3.1",
13
+ "jest-environment-jsdom": "^29.3.1",
10
14
  "prettier": "2.8.2"
11
15
  },
12
16
  "directories": {
@@ -20,6 +24,9 @@
20
24
  "hellotext",
21
25
  "javascript"
22
26
  ],
27
+ "scripts": {
28
+ "test": "NODE_ENV=test jest"
29
+ },
23
30
  "bugs": {
24
31
  "url": "https://github.com/hellotext/hellotext.js/issues"
25
32
  }