Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
54317f9
Add popup runtime integration
anyelopetit Aug 11, 2026
f1dc1a8
Fix Cursor Bugbot: Last-step submit skips validation
anyelopetit Aug 12, 2026
8967e15
popups: recover runtime bundle after rebase
anyelopetit Aug 21, 2026
e2ce7dd
popups: interpolate completion values in preview and runtime
anyelopetit Aug 24, 2026
b13c39e
popups: add idempotency key to popup submissions
anyelopetit Aug 24, 2026
b468ecb
popups: style runtime completion buttons
anyelopetit Aug 24, 2026
2026107
popups: add resend verification flow to runtime sdk
anyelopetit Aug 25, 2026
509c3ec
popups: sync runtime delivery state
anyelopetit Aug 27, 2026
14cd979
popups: adjust completed step typography
anyelopetit Aug 27, 2026
0777465
popups: load active dashboard popups safely
anyelopetit Aug 29, 2026
ba88ba4
popups: prepare runtime sdk release
anyelopetit Aug 29, 2026
f856e9e
popups: enforce single popup runtime
anyelopetit Aug 31, 2026
96f2709
popups: cancel submissions before changing destination
anyelopetit Aug 31, 2026
65d8405
popups: make submissions safely retryable
anyelopetit Aug 31, 2026
8c4acba
popups: slim runtime styles and events
anyelopetit Sep 3, 2026
5bfa727
popups: rebuild runtime bundle after rebase
anyelopetit Sep 3, 2026
ec6ab1c
popups: simplify controller initialization and visibility
rockwellll Sep 5, 2026
2a298d2
popups: mount without waiting for stylesheets
rockwellll Sep 5, 2026
9ea29ee
popups: emit mounted lifecycle event
rockwellll Sep 5, 2026
1a23c9a
popups: document controller methods and intent
rockwellll Sep 5, 2026
d69b7ee
docs: add popup configuration and lifecycle guide
rockwellll Sep 5, 2026
cd8ca8c
popups: use direct visibility and API calls
rockwellll Sep 5, 2026
3eef9a9
sdk: simplify initialization and remove runtime rollback
rockwellll Sep 5, 2026
fb85460
sdk: rebuild runtime after popup and initialization cleanup
rockwellll Sep 5, 2026
b087d36
sdk: load configured widgets concurrently
rockwellll Sep 5, 2026
64760c1
sdk: regenerate package and browser builds
rockwellll Sep 5, 2026
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
44 changes: 33 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This library allows you the following,
- Track events happening on your site to [Hellotext](https://www.hellotext.com) in real-time.
- Use Hellotext Forms to dynamically collect data from your customers based on your specific business requirements.
- Use Hellotext Webchat to interact with your customers in real-time.
- Use Hellotext Popups to collect customer information from dashboard-built popups.

## Installation

Expand Down Expand Up @@ -55,30 +56,31 @@ Learn how to leverage the library to track events and collect forms.
- [Tracking Events](/docs/tracking.md)
- [Forms](/docs/forms.md)
- [Webchat](/docs/webchat.md)
- [Popups](/docs/popups.md)

## CSS

The library ships with a minimal CSS file that is used for [Forms](/docs/forms.md). It is pre-bundled but you control when to import it.

## For Bundler Users (Vite, Webpack, etc.)

The CSS is not automatically imported to avoid issues with SSR frameworks. Import it separately:
The generic form CSS is not automatically imported to avoid issues with SSR frameworks. Import it separately:

```javascript
// Import the library
import Hellotext from '@hellotext/hellotext'

// Import the CSS separately
// Import the generic form CSS separately
import '@hellotext/hellotext/styles/index.css'
```

## For Script Tag Users

The UMD bundle (`dist/hellotext.js`) includes the CSS automatically:
The UMD bundle (`dist/hellotext.js`) includes generic form CSS automatically. Popup styles are served by Hellotext with the public business configuration:

```html
<script src="https://unpkg.com/@hellotext/hellotext"></script>
<!-- CSS is included in the bundle -->
<!-- Business runtime styles are loaded from Hellotext -->
```

## Events
Expand All @@ -104,6 +106,9 @@ Hellotext.removeEventListener(eventName, callback)
- `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.
- `popup:mounted` This event is fired when a popup is mounted, before its initial display state is evaluated.
- `popup:opened` This event is fired when a popup dialog becomes visible.
- `popup:closed` This event is fired when a popup is dismissed.
- 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.

Expand All @@ -117,10 +122,27 @@ Hellotext.initialize('HELLOTEXT_BUSINESS_ID', configurationOptions)

#### Configuration Options

| Property | Description | Type | Default |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ----------------------------------------- |
| session | A valid Hellotext session which was stored previously. When not set, Hellotext attempts to retrieve the stored value from `document.cookie` when available, otherwise it creates a new session. | String | null |
| 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 |
| Property | Description | Type | Default |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ----------------------------------------- |
| session | A valid Hellotext session which was stored previously. When not set, Hellotext attempts to retrieve the stored value from `document.cookie` when available, otherwise it creates a new session. | String | null |
| 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 } |
| popup | Options for the dashboard popup, or `false` to disable automatic popup mounting. See [Popups](/docs/popups.md). | Object \| false | Dashboard popup when configured |
| 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 |

#### Popup

```javascript
Hellotext.initialize('HELLOTEXT_BUSINESS_ID', {
popup: {
id: 'POPUP_ID',
},
})
```

When a popup is installed automatically from the dashboard, `Hellotext.initialize('HELLOTEXT_BUSINESS_ID')` mounts the configured popup without passing `popup.id` manually. Only one popup can be installed at a time.

The popup is rendered from its dashboard configuration, including steps, fields, layout, bubble, and colors. Passing an explicit `popup.id` loads that popup.

See [Popups](/docs/popups.md) for configuration, display behaviour, validation, completion, and [events](/docs/popups.md#events).
160 changes: 160 additions & 0 deletions __tests__/api/popups_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* @jest-environment jsdom
*/

import PopupsAPI from '../../src/api/popups'
import Hellotext from '../../src/hellotext'
import { Configuration } from '../../src/core'
import { Locale } from '../../src/core/configuration/locale'

describe('PopupsAPI', () => {
beforeEach(() => {
Configuration.apiRoot = 'https://api.hellotext.test/v1'
Configuration.popup.device = 'desktop'
Locale._identifier = 'es'
Hellotext.business = {
id: 'business-id',
data: null,
setData: jest.fn(),
setLocale: jest.fn(),
}

jest.spyOn(Hellotext, 'session', 'get').mockReturnValue('session-123')
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
business: { id: 'business-id' },
html: '<article id="popup-widget" data-controller="hellotext--popup"></article>',
locale: 'es',
}),
})
})

afterEach(() => {
jest.restoreAllMocks()
Configuration.apiRoot = 'https://api.hellotext.com/v1'
Configuration.popup.id = undefined
Configuration.popup.container = 'body'
Configuration.popup.device = 'auto'
Locale._identifier = undefined
})

it('fetches the public popup with session, locale, and device params', async () => {
const element = await PopupsAPI.get('popup-id')
const url = new URL(global.fetch.mock.calls[0][0])

expect(url.pathname).toBe('/v1/public/popups/popup-id')
expect(url.searchParams.get('session')).toBe('session-123')
expect(url.searchParams.get('locale')).toBe('es')
expect(url.searchParams.get('device')).toBe('desktop')
expect(global.fetch.mock.calls[0][1].headers.Authorization).toBe('Bearer business-id')
expect(element.id).toBe('popup-widget')
expect(Hellotext.business.setData).toHaveBeenCalledWith({ id: 'business-id' })
expect(Hellotext.business.setLocale).toHaveBeenCalledWith('es')
})

it('resolves the automatic device from the viewport before requesting markup', async () => {
Configuration.popup.device = 'auto'
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 767 })

await PopupsAPI.get('popup-id')

const url = new URL(global.fetch.mock.calls[0][0])
expect(url.searchParams.get('device')).toBe('mobile')
})

it('returns null when the popup request fails', async () => {
global.fetch.mockResolvedValue({ ok: false })

await expect(PopupsAPI.get('popup-id')).resolves.toBeNull()
})

it('returns null when the popup request errors', async () => {
global.fetch.mockRejectedValue(new Error('Network error'))

await expect(PopupsAPI.get('popup-id')).resolves.toBeNull()
})

it('returns null when the popup response is invalid JSON', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: jest.fn().mockRejectedValue(new Error('Invalid JSON')),
})

await expect(PopupsAPI.get('popup-id')).resolves.toBeNull()
})

it('submits popup data with the current session', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ id: 'submission-id' }),
})

const response = await PopupsAPI.submit(
'popup-id',
{
email: 'customer@example.com',
metadata: { fields: { email: 'customer@example.com' } },
},
'submission-attempt-id',
)

const request = global.fetch.mock.calls[0]
const body = JSON.parse(request[1].body)

expect(request[0]).toBe('https://api.hellotext.test/v1/public/popups/popup-id/submissions')
expect(request[1].method).toBe('POST')
expect(request[1].headers.Authorization).toBe('Bearer business-id')
expect(request[1].headers['Idempotency-Key']).toBe('submission-attempt-id')
expect(body).toEqual({
session: 'session-123',
popup_submission: {
email: 'customer@example.com',
metadata: {
fields: {
email: 'customer@example.com',
},
},
},
})
expect(response.succeeded).toBe(true)
})

it('generates an idempotency key when the caller does not provide one', async () => {
await PopupsAPI.submit('popup-id', {})

expect(global.fetch.mock.calls[0][1].headers['Idempotency-Key']).toMatch(
/^[a-zA-Z0-9._:-]+$/,
)
})

it('resends verification through the route stored by the backend', async () => {
const response = await PopupsAPI.resend('popup-id', 'submission-id', 'action-token')
const request = global.fetch.mock.calls[0]

expect(request[0]).toBe(
'https://api.hellotext.test/v1/public/popups/popup-id/submissions/submission-id/resend',
)
expect(request[1]).toEqual({
method: 'POST',
headers: Hellotext.headers,
body: JSON.stringify({ token: 'action-token' }),
})
expect(response.succeeded).toBe(true)
})

it('cancels the previous submission before changing its destination', async () => {
const response = await PopupsAPI.cancel('popup-id', 'submission-id', 'action-token')
const request = global.fetch.mock.calls[0]

expect(request[0]).toBe(
'https://api.hellotext.test/v1/public/popups/popup-id/submissions/submission-id/cancel',
)
expect(request[1]).toEqual({
method: 'POST',
headers: Hellotext.headers,
body: JSON.stringify({ token: 'action-token' }),
})
expect(response.succeeded).toBe(true)
})
})
Loading