Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 64 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,39 +61,89 @@ Follow these guides to set up tracking, forms, chat widgets, and Push notificati

## Events

This library emits events that you can listen to and perform specific action when the event happens.
Think of it like `addEventListener` for HTML elements. You can listen for events, and remove events as well.

To listen to an event, you can call the `on` method, like so
Use `Hellotext.on` to listen for SDK events. The callback receives the event's payload directly.
Register listeners before `Hellotext.initialize` to receive events emitted during initialization.

```javascript
Hellotext.on(eventName, callback)
```

To remove an event listener, you can call `removeEventListener`
To unsubscribe, pass the same callback to `Hellotext.removeEventListener`:

```javascript
Hellotext.removeEventListener(eventName, callback)
```

### List of events
### Sessions and attribution

| Event | When it fires | Callback payload |
| --- | --- | --- |
| `session-set` | The session value is set, including when an existing session is restored during initialization. | The current session value, also available as `Hellotext.session`. |
| `utm-set` | UTM parameters are saved. | A JSON string containing the saved UTM parameters and `observed_at`. Use `JSON.parse` to read it as an object. |

See [Understanding Sessions](/docs/sessions.md) and [Tracking Events](/docs/tracking.md).

### Forms

| Event | When it fires | Callback payload |
| --- | --- | --- |
| `forms:collected` | Forms found on the page have finished loading, before automatic mounting. | The `FormCollection` instance, with methods such as `getById`, `getByIndex`, and `forEach`. |
| `form:completed` | A form completes, or a previously completed form is restored from local storage during mounting. | `{ id, state, data, completedAt }`, where `data` contains the submitted values and `completedAt` is a timestamp in milliseconds. |

See [Forms](/docs/forms.md) for collection, mounting, and completion details.

### Smart Alerts

Each alert event receives `{ kind }`, where `kind` identifies the section: `homepage`,
`product_collection`, or `product_details`.

| Event | When it fires | Callback payload |
| --- | --- | --- |
| `alert:shown` | A section is displayed by a successful `show` call, including forced calls. | `{ kind }` |
| `alert:dismissed` | The visitor clicks the secondary action, hiding the alert and starting its dismissal cooldown. | `{ kind }` |
| `alert:accepted` | The visitor clicks the primary action, before the browser permission result or subscription completion. | `{ kind }` |

`alert:accepted` does not confirm that the visitor granted permission or subscribed.
Programmatic hiding, cleanup, and dismissals received from another tab do not emit `alert:dismissed`.

```javascript
Hellotext.on('alert:accepted', ({ kind }) => {
console.log('Alert accepted in', kind)
})
```

See [Smart Alerts](/docs/push.md#show-a-smart-alert) for display options and dismissal behavior.

### Webchat

| Event | When it fires | Callback payload |
| --- | --- | --- |
| `webchat:mounted` | The Webchat widget is mounted. | None. |
| `webchat:opened` | The Webchat conversation opens. | None. |
| `webchat:closed` | The Webchat conversation closes. | None. |
| `webchat:message:sent` | A visitor's message or quick reply is successfully sent. | The message object, including `id`, `body`, and `attachments`, with additional context for quick replies and product cards. |
| `webchat:message:received` | An incoming message is added to the Webchat conversation. | The message object, with `body` containing its displayed text. |

See [Webchat events](/docs/webchat.md#events) for message payload examples.

### Cart

| Event | When it fires | Callback payload |
| --- | --- | --- |
| `cart.added` | The visitor clicks an add-to-cart button in a Webchat product card. | `{ object_parameters: { items }, source }`. Each item contains `product`, `quantity`, and optional `reference` and `source`; the outer `source` contains `kind`, `message_id`, and `button_id`. |

- `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.
- `utm-set`: this event is fired when the UTM value is collected, useful to store the UTM on your side.
- `forms:collected` This event is fired when forms are collected. The callback will receive the array of forms collected.
- `form:completed` This event is fired when a form has been completed. A form is completed when the user fills all required inputs and verifies their OTP(One-Time Password). The callback will receive the form object that was completed, alongside the data the user filled in the form.
- View Webchat events [here](/docs/webchat.md#events)
- `cart.added` This event is fired when a customer adds a product to their cart from a Webchat message.
Handle `cart.added` in your storefront integration to update the cart. This event records the button
click; it does not confirm that your storefront added the item successfully.

### Configuration
## Configuration

When initializing the library, you may pass an optional configuration object as the second argument.

```javascript
Hellotext.initialize('HELLOTEXT_BUSINESS_ID', configurationOptions)
```

#### Configuration Options
### Configuration Options

| Property | Description | Type | Default |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ----------------------------------------- |
Expand Down
180 changes: 180 additions & 0 deletions __tests__/alert_initialization_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { Application } from '@hotwired/stimulus'
import Hellotext from '../src/hellotext'
import API from '../src/api'
import { Business, Push } from '../src/models'
import AlertController from '../src/controllers/alert_controller'

const html = `
<article hidden data-controller="hellotext--alert"
data-hellotext--alert-sections-value='[{"kind":"homepage","title":"Store updates","description":"New arrivals","primary_action":"Activate alerts","secondary_action":"Not now"}]'>
<h4 data-hellotext--alert-target="title"></h4>
<p data-hellotext--alert-target="description"></p>
<button data-hellotext--alert-target="primaryAction"
data-action="click->hellotext--alert#subscribe"></button>
<button data-hellotext--alert-target="secondaryAction"
data-action="click->hellotext--alert#hide"></button>
</article>
`

const businessData = (overrides = {}) => ({
id: 'alert-initialization-business',
locale: 'en',
style_url: 'https://example.com/hellotext.css',
webchat: null,
whatsapp: null,
push: { public_key: 'business-public-key' },
alert: { html },
...overrides,
})

const deferred = () => {
let resolve
const promise = new Promise(done => { resolve = done })
return { promise, resolve }
}

describe('Smart Alert initialization', () => {
let application
let supported
let stylesheetLoaded
let notificationDescriptor
let forms

const hydrate = data => {
API.businesses.get.mockResolvedValue({ ok: true, json: async () => data })
}

const initialize = async (id = 'alert-initialization-business', config = {}) => {
await Hellotext.initialize(id, config)
forms.push(Hellotext.forms)
}

beforeEach(async () => {
document.body.innerHTML = ''
localStorage.clear()
forms = []
notificationDescriptor = Object.getOwnPropertyDescriptor(window, 'Notification')
Object.defineProperty(window, 'Notification', {
configurable: true,
value: { permission: 'default' },
})
supported = jest.spyOn(Push, 'supported', 'get').mockReturnValue(true)
jest.spyOn(Push.prototype, 'initialize').mockImplementation(function () {
this.ready = Promise.resolve()
return this.ready
})
stylesheetLoaded = jest.spyOn(Business, 'waitForStylesheet').mockResolvedValue(true)
jest.spyOn(API.businesses, 'get')
jest.spyOn(API.pushAlerts, 'create').mockResolvedValue({ succeeded: true })
hydrate(businessData())
application = new Application(document.documentElement)
application.register('hellotext--alert', AlertController)
await application.start()
})

afterEach(() => {
Hellotext.alert?.dispose()
Hellotext.alert = null
Hellotext.push?.dispose()
Hellotext.push = null
forms.forEach(collection => collection.mutationObserver?.disconnect())
application.stop()
document.body.innerHTML = ''
document.querySelectorAll('link[data-hellotext-stylesheet]').forEach(link => link.remove())
jest.restoreAllMocks()
if (notificationDescriptor) {
Object.defineProperty(window, 'Notification', notificationDescriptor)
} else {
delete window.Notification
}
})

it('exposes a hidden alert that can show server-provided sections', async () => {
await initialize()
await Hellotext.alert.ready

expect(document.querySelector('article').hidden).toBe(true)
await expect(Hellotext.alert.show('homepage')).resolves.toBe(true)
expect(document.querySelector('h4').textContent).toBe('Store updates')
expect(document.querySelector('article').hidden).toBe(false)
})

it.each([
['the alert payload is missing', { alert: undefined }],
['the playbook is disabled', { alert: null }],
['the public key is missing', { push: {} }],
])('does not create an alert when %s', async (_reason, overrides) => {
hydrate(businessData(overrides))

await initialize()

expect(Hellotext.alert).toBeNull()
expect(document.querySelector('article')).toBeNull()
})

it('does not create an alert when Push is disabled in page configuration', async () => {
await initialize('alert-initialization-business', { push: false })

expect(Hellotext.alert).toBeNull()
expect(Hellotext.push).toBeNull()
expect(document.querySelector('article')).toBeNull()
})

it('does not create an alert when the browser lacks Push support', async () => {
supported.mockReturnValue(false)

await initialize()

expect(Hellotext.alert).toBeNull()
expect(document.querySelector('article')).toBeNull()
})

it('removes the old alert when reinitialized with Push disabled', async () => {
await initialize()
const previous = Hellotext.alert
await previous.show('homepage')

await initialize('alert-initialization-business', { push: false })

expect(previous.disposed).toBe(true)
expect(previous.push.disposed).toBe(true)
expect(Hellotext.alert).toBeNull()
expect(document.querySelector('article')).toBeNull()
})

it('prevents a pending old alert from mounting after another business initializes', async () => {
const previousStylesheet = deferred()
stylesheetLoaded.mockReturnValue(previousStylesheet.promise)
await initialize('business-a')
const previous = Hellotext.alert
const previousShown = previous.show('homepage')

stylesheetLoaded.mockResolvedValue(true)
hydrate(businessData({ id: 'business-b', style_url: 'https://example.com/business-b.css' }))
await initialize('business-b')
await expect(Hellotext.alert.show('homepage')).resolves.toBe(true)
previousStylesheet.resolve(true)

await expect(previousShown).resolves.toBe(false)
expect(previous.disposed).toBe(true)
expect(document.querySelectorAll('article')).toHaveLength(1)
expect(document.querySelector('article')).toBe(Hellotext.alert.element)
expect(Hellotext.alert.business.id).toBe('business-b')
})

it('ignores an earlier hydration that finishes after Push was disabled for another business', async () => {
const previousResponse = deferred()
API.businesses.get.mockReturnValueOnce(previousResponse.promise)
const previousInitialization = initialize('business-a')

hydrate(businessData({ id: 'business-b' }))
await initialize('business-b', { push: false })
previousResponse.resolve({ ok: true, json: async () => businessData({ id: 'business-a' }) })
await previousInitialization

expect(Hellotext.business.id).toBe('business-b')
expect(Hellotext.push).toBeNull()
expect(Hellotext.alert).toBeNull()
expect(document.querySelector('article')).toBeNull()
})
})
59 changes: 59 additions & 0 deletions __tests__/api/push/alerts_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import API from '../../../src/api'
import Hellotext from '../../../src/hellotext'
import { Configuration } from '../../../src/core'

describe('PushAlertsAPI', () => {
const defaultApiRoot = Configuration.apiRoot
let previousBusiness

beforeEach(() => {
previousBusiness = Hellotext.business
Configuration.apiRoot = 'https://api.hellotext.test/v1'
Hellotext.business = { id: 'business-id' }
jest.spyOn(Hellotext, 'session', 'get').mockReturnValue('session-id')
global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 204 })
})

afterEach(() => {
jest.restoreAllMocks()
Configuration.apiRoot = defaultApiRoot
Hellotext.business = previousBusiness
})

it.each(['shown', 'dismissed', 'accepted'])('posts %s with the current session, section, and page', async kind => {
const page = { url: 'https://shop.example.com/?utm_source=email#offers', title: 'Shop', path: '/' }
const response = await API.pushAlerts.create({ section: 'homepage', kind, page })

expect(global.fetch).toHaveBeenCalledWith('https://api.hellotext.test/v1/public/push/alerts', {
method: 'POST',
keepalive: true,
headers: {
Authorization: 'Bearer business-id',
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ session: 'session-id', section: 'homepage', kind, page }),
})
expect(response.succeeded).toBe(true)
})

it('uses the latest SDK session for each request', async () => {
await API.pushAlerts.create({ section: 'homepage', kind: 'shown' })
jest.spyOn(Hellotext, 'session', 'get').mockReturnValue('next-session-id')
await API.pushAlerts.create({ section: 'product_details', kind: 'accepted' })

expect(JSON.parse(global.fetch.mock.calls[1][1].body)).toEqual({
session: 'next-session-id',
section: 'product_details',
kind: 'accepted',
})
})

it('returns a failed response when the endpoint rejects the interaction', async () => {
global.fetch.mockResolvedValue({ ok: false, status: 422 })

const response = await API.pushAlerts.create({ section: 'homepage', kind: 'shown' })

expect(response.failed).toBe(true)
})
})
Loading