Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,4 @@ Hellotext.initialize('HELLOTEXT_BUSINESS_ID', configurationOptions)
| autoGenerateSession | Whether the library should automatically generate a session when no session is found in the query or the cookies | Boolean | true |
| forms | An object that controls how Hellotext should control the forms on the page. See [Forms](/docs/forms.md) documentation for more information. | Object | { autoMount: true, successMessage: true } |
| webchat | An object that overrides the dashboard webchat configuration, or `false` to disable automatic webchat mounting. See [Webchat](/docs/webchat.md). | Object \| false | Dashboard webchat when configured |
| whatsappWidget | An object that overrides the dashboard WhatsApp widget configuration, or `false` to disable automatic WhatsApp widget mounting. | Object \| false | Dashboard WhatsApp widget when configured |
55 changes: 55 additions & 0 deletions __tests__/api/whatsapp_widgets_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @jest-environment jsdom
*/

import WhatsAppWidgetsAPI from '../../src/api/whatsapp_widgets'
import Hellotext from '../../src/hellotext'
import { Configuration } from '../../src/core'

describe('WhatsAppWidgetsAPI', () => {
beforeEach(() => {
Configuration.apiRoot = 'https://api.hellotext.test/v1'
Configuration.whatsapp.placement = 'top-left'
Configuration.whatsapp.appearance = {
launcher: {
iconUrl: 'https://example.com/whatsapp.png'
}
}
Configuration.whatsapp.number = '+15551234567'
Configuration.whatsapp.body = 'Hello from install'
Hellotext.business = {
id: 'business-id',
data: { id: 'business-id' },
setData: jest.fn(),
setLocale: jest.fn()
}
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
business: { id: 'business-id' },
html: '<article id="whatsapp-widget"></article>'
})
})
})

afterEach(() => {
jest.restoreAllMocks()
Configuration.apiRoot = 'https://api.hellotext.com/v1'
Configuration.whatsapp.placement = 'bottom-right'
Configuration.whatsapp.appearance = {}
Configuration.whatsapp.number = null
Configuration.whatsapp.body = null
})

it('fetches the public WhatsApp widget with placement, appearance, number, and body overrides', () => {
return WhatsAppWidgetsAPI.get('widget-id').then(element => {
const url = new URL(global.fetch.mock.calls[0][0])

expect(url.pathname).toBe('/v1/widgets/whatsapp/widget-id')
expect(url.searchParams.get('placement')).toBe('top-left')
expect(url.searchParams.get('whatsapp[appearance][launcher][icon_url]')).toBe('https://example.com/whatsapp.png')
expect(url.searchParams.get('whatsapp[number]')).toBe('+15551234567')
expect(url.searchParams.get('whatsapp[body]')).toBe('Hello from install')
expect(element.id).toBe('whatsapp-widget')
})
})
})
44 changes: 44 additions & 0 deletions __tests__/core/whatsapp_configuration_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { WhatsApp } from '../../src/core'

describe('WhatsApp configuration', () => {
afterEach(() => {
WhatsApp.id = undefined
WhatsApp.container = 'body'
WhatsApp.placement = 'bottom-right'
WhatsApp.appearance = {}
WhatsApp.number = null
WhatsApp.body = null
})

it('assigns valid widget options', () => {
WhatsApp.assign({
id: 'widget-id',
container: '#widget-container',
placement: 'top-left',
appearance: {
launcher: {
iconUrl: 'https://example.com/whatsapp.png'
}
},
number: '+15551234567',
body: 'Hello from install'
})

expect(WhatsApp.id).toBe('widget-id')
expect(WhatsApp.container).toBe('#widget-container')
expect(WhatsApp.placement).toBe('top-left')
expect(WhatsApp.appearance.launcher.iconUrl).toBe('https://example.com/whatsapp.png')
expect(WhatsApp.number).toBe('+15551234567')
expect(WhatsApp.body).toBe('Hello from install')
})

it('rejects invalid launcher appearance keys', () => {
expect(() => {
WhatsApp.appearance = {
launcher: {
color: '#25D366'
}
}
}).toThrow('Invalid appearance launcher property: color')
})
})
88 changes: 87 additions & 1 deletion __tests__/hellotext_test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Hellotext from "../src/hellotext";
import API from "../src/api";
import { Configuration } from "../src/core";
import { Session, Webchat } from "../src/models";
import { Session, Webchat, WhatsAppWidget } from "../src/models";

const getCookieValue = name => document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop()

Expand Down Expand Up @@ -48,17 +48,26 @@ describe("when trying to call methods before initializing the class", () => {

describe("when initializing business metadata", () => {
let loadWebchat
let loadWhatsAppWidget

beforeEach(() => {
loadWebchat = jest.spyOn(Webchat, 'load').mockResolvedValue({})
loadWhatsAppWidget = jest.spyOn(WhatsAppWidget, 'load').mockResolvedValue({})
})

afterEach(() => {
loadWebchat.mockRestore()
loadWhatsAppWidget.mockRestore()
Configuration.webchat.behaviour = null
Configuration.webchat.behaviourOverride = false
Configuration.webchat.appearance = {}
Configuration.webchat.whatsapp = {}
Configuration.whatsapp.id = undefined
Configuration.whatsapp.container = 'body'
Configuration.whatsapp.placement = 'bottom-right'
Configuration.whatsapp.appearance = {}
Configuration.whatsapp.number = null
Configuration.whatsapp.body = null
})

it("fetches public business data by default and stores it", async () => {
Expand Down Expand Up @@ -204,6 +213,83 @@ describe("when initializing business metadata", () => {
expect(loadWebchat).not.toHaveBeenCalled()
})

it("loads the dashboard WhatsApp widget when no explicit WhatsApp config is passed", async () => {
mockBusinessFetch(defaultBusiness({ whatsapp: { id: "dashboard-whatsapp-widget" } }))

await Hellotext.initialize("xy76ks")

expect(loadWhatsAppWidget).toHaveBeenCalledWith("dashboard-whatsapp-widget")
})

it("loads dashboard webchat and WhatsApp widget together", async () => {
mockBusinessFetch(defaultBusiness({
webchat: { id: "dashboard-webchat" },
whatsapp: { id: "dashboard-whatsapp-widget" },
}))

await Hellotext.initialize("xy76ks")

expect(loadWebchat).toHaveBeenCalledWith("dashboard-webchat")
expect(loadWhatsAppWidget).toHaveBeenCalledWith("dashboard-whatsapp-widget")
})

it("deep merges explicit local WhatsApp widget options with dashboard defaults", async () => {
mockBusinessFetch(defaultBusiness({
whatsapp: {
id: "dashboard-whatsapp-widget",
placement: "bottom-left",
appearance: {
launcher: {
iconUrl: "https://example.com/dashboard-whatsapp.png",
},
},
},
}))

await Hellotext.initialize("xy76ks", {
whatsappWidget: {
container: "#whatsapp-container",
appearance: {
launcher: {
iconUrl: "https://example.com/local-whatsapp.png",
},
},
},
})

expect(loadWhatsAppWidget).toHaveBeenCalledWith("dashboard-whatsapp-widget")
expect(Configuration.whatsapp.container).toEqual("#whatsapp-container")
expect(Configuration.whatsapp.placement).toEqual("bottom-left")
expect(Configuration.whatsapp.appearance).toEqual({
launcher: {
iconUrl: "https://example.com/local-whatsapp.png",
},
})
})

it("accepts whatsappWidget as the public WhatsApp widget config name", async () => {
mockBusinessFetch(defaultBusiness({ whatsapp: { id: "dashboard-whatsapp-widget" } }))

await Hellotext.initialize("xy76ks", {
whatsappWidget: {
number: "+15551234567",
body: "Hello from install",
},
})

expect(loadWhatsAppWidget).toHaveBeenCalledWith("dashboard-whatsapp-widget")
expect(Configuration.whatsapp.number).toEqual("+15551234567")
expect(Configuration.whatsapp.body).toEqual("Hello from install")
})

it("skips WhatsApp widget loading when whatsappWidget is false", async () => {
mockBusinessFetch(defaultBusiness({ whatsapp: { id: "dashboard-whatsapp-widget" } }))

await Hellotext.initialize("xy76ks", { whatsappWidget: false })

expect(loadWhatsAppWidget).not.toHaveBeenCalled()
})

it("does not break initialization when business fetch rejects", async () => {
API.businesses.get = jest.fn().mockRejectedValue(new Error("network error"))

Expand Down
107 changes: 107 additions & 0 deletions __tests__/models/whatsapp_widget_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @jest-environment jsdom
*/

import API from '../../src/api'
import { Configuration } from '../../src/core'
import { WhatsAppWidget } from '../../src/models'

describe('WhatsAppWidget', () => {
const createStylesheet = ({ loaded = true } = {}) => {
const linkTag = document.createElement('link')
linkTag.rel = 'stylesheet'
linkTag.href = 'https://example.com/hellotext.css'
linkTag.setAttribute('data-hellotext-stylesheet', 'true')

if (loaded) {
linkTag.dataset.hellotextStylesheetLoaded = 'true'
}

document.head.appendChild(linkTag)

return linkTag
}

const markStylesheetLoaded = linkTag => {
Object.defineProperty(linkTag, 'sheet', {
value: {},
configurable: true
})
linkTag.dispatchEvent(new Event('load'))
}

beforeEach(() => {
document.body.innerHTML = '<main id="whatsapp-container"></main>'
Configuration.whatsapp.container = '#whatsapp-container'
jest.spyOn(API.whatsappWidgets, 'get')
})

afterEach(() => {
jest.restoreAllMocks()
document.body.innerHTML = ''
document.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
link.dispatchEvent(new Event('error'))
link.remove()
})
Configuration.whatsapp.container = 'body'
})

it('waits for the stylesheet before appending the widget HTML', () => {
const linkTag = createStylesheet({ loaded: false })
const article = document.createElement('article')
API.whatsappWidgets.get.mockResolvedValue(article)

return WhatsAppWidget.load('widget-id').then(widget => {
expect(document.querySelector('#whatsapp-container article')).toBeNull()

markStylesheetLoaded(linkTag)

return widget.rendered.then(() => {
expect(document.querySelector('#whatsapp-container article')).toBe(article)
expect(widget.mounted).toBe(true)
})
})
})

it('marks itself when webchat is already mounted', () => {
createStylesheet()
document.body.insertAdjacentHTML('beforeend', '<article class="hellotext--webchat"></article>')
const article = document.createElement('article')
article.className = 'hellotext--webchat hellotext--whatsapp-widget'
API.whatsappWidgets.get.mockResolvedValue(article)

return WhatsAppWidget.load('widget-id').then(widget => {
return widget.rendered.then(() => {
expect(widget.mounted).toBe(true)
expect(article.classList.contains('hellotext--with-webchat')).toBe(true)
expect(document.querySelector('.hellotext--webchat:not(.hellotext--whatsapp-widget)').classList.contains('hellotext--with-whatsapp-widget')).toBe(true)
})
})
})

it('does not mount when the API returns no widget HTML', () => {
createStylesheet()
API.whatsappWidgets.get.mockResolvedValue(null)

return WhatsAppWidget.load('widget-id').then(widget => {
return widget.rendered.then(() => {
expect(document.querySelector('#whatsapp-container').children.length).toBe(0)
expect(widget.mounted).toBe(false)
})
})
})

it('does not mount when the configured container is missing', () => {
createStylesheet()
Configuration.whatsapp.container = '#missing-container'
jest.spyOn(console, 'warn').mockImplementation(() => {})
API.whatsappWidgets.get.mockResolvedValue(document.createElement('article'))

return WhatsAppWidget.load('widget-id').then(widget => {
return widget.rendered.then(() => {
expect(widget.mounted).toBe(false)
expect(console.warn).toHaveBeenCalledWith('Hellotext WhatsApp widget was not mounted because the container #missing-container was not found.')
})
})
})
})
2 changes: 1 addition & 1 deletion dist/hellotext.js

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions docs/whatsapp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# WhatsApp Widget

Hellotext can load a configured WhatsApp widget from the dashboard:

```javascript
Hellotext.initialize('PUBLIC_BUSINESS_ID')
```

Install-level configuration can override dashboard settings. Passing `whatsappWidget: false` disables the automatic WhatsApp widget mount.

```javascript
Hellotext.initialize('PUBLIC_BUSINESS_ID', { whatsappWidget: false })
```

You can also load a specific widget:

```javascript
Hellotext.initialize('PUBLIC_BUSINESS_ID', {
whatsappWidget: {
id: 'WHATSAPP_WIDGET_ID',
number: '+15551234567',
body: 'Hello, I need help'
}
})
```

## Options

| Property | Description | Type | Default |
| -------- | ----------- | ---- | ------- |
| id | The id of the WhatsApp widget to load. Overrides the dashboard widget id when provided. | String | Dashboard id |
| container | The container to append the widget to. | String | `body` |
| placement | The placement of the widget. | Enum | `bottom-right` |
| number | The WhatsApp number used for the `wa.me` link. Overrides the dashboard handoff number. | String | Dashboard |
| body | The prefilled WhatsApp compose text. Overrides the dashboard Message component body. | String | Dashboard |
| appearance | Appearance overrides for the configured widget. | Object | Dashboard |

The widget opens WhatsApp directly through `wa.me`.
Loading