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
20 changes: 20 additions & 0 deletions __tests__/api/whatsapp_widgets_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,26 @@ describe('WhatsAppWidgetsAPI', () => {
})
})

it('sends the session so the view is counted', () => {
jest.spyOn(Hellotext, 'session', 'get').mockReturnValue('session-id')

return WhatsAppWidgetsAPI.get('widget-id').then(() => {
const url = new URL(global.fetch.mock.calls[0][0])

expect(url.searchParams.get('session')).toBe('session-id')
})
})

it('sends no session when there is none', () => {
jest.spyOn(Hellotext, 'session', 'get').mockReturnValue(undefined)

return WhatsAppWidgetsAPI.get('widget-id').then(() => {
const url = new URL(global.fetch.mock.calls[0][0])

expect(url.searchParams.has('session')).toBe(false)
})
})

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

Expand Down
59 changes: 58 additions & 1 deletion __tests__/models/whatsapp_widget_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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

describe('WhatsAppWidget', () => {
const createStylesheet = ({ loaded = true } = {}) => {
Expand Down Expand Up @@ -79,6 +79,63 @@ describe('WhatsAppWidget', () => {
})
})

describe('links', () => {
const clickUrl = 'https://api.hellotext.test/v1/public/widgets/whatsapp/widget-id/click'

const widgetHtml = () => {
const article = document.createElement('article')
article.setAttribute('data-hellotext--whatsapp-widget-click-url-value', clickUrl)
article.innerHTML = `
<a class="hellotext--webchat-teaser-message" href="${clickUrl}?number=15551234567&text=Hola+desde+la+tienda">Hola</a>
<a class="hellotext--webchat-trigger" href="${clickUrl}?number=15551234567&text=Hola+desde+la+tienda"></a>
<a class="other" href="https://shop.example.test/help">Help</a>
`

return article
}

const query = href => Object.fromEntries(new URL(href).searchParams)

it('leads every widget link through the click endpoint with the session', () => {
createStylesheet()
jest.spyOn(Session, 'session', 'get').mockReturnValue('session-id')
const article = widgetHtml()
API.whatsappWidgets.get.mockResolvedValue(article)

return WhatsAppWidget.load('widget-id').then(widget => widget.rendered).then(() => {
;['.hellotext--webchat-trigger', '.hellotext--webchat-teaser-message'].forEach(selector => {
const href = article.querySelector(selector).getAttribute('href')

expect(href.startsWith(`${clickUrl}?`)).toBe(true)
expect(query(href)).toEqual({ number: '15551234567', text: 'Hola desde la tienda', session: 'session-id' })
})
})
})

it('leaves links that do not lead to the click endpoint alone', () => {
createStylesheet()
jest.spyOn(Session, 'session', 'get').mockReturnValue('session-id')
const article = widgetHtml()
API.whatsappWidgets.get.mockResolvedValue(article)

return WhatsAppWidget.load('widget-id').then(widget => widget.rendered).then(() => {
expect(article.querySelector('.other').getAttribute('href')).toBe('https://shop.example.test/help')
})
})

it('keeps the rendered links without a session', () => {
createStylesheet()
jest.spyOn(Session, 'session', 'get').mockReturnValue(undefined)
const article = widgetHtml()
API.whatsappWidgets.get.mockResolvedValue(article)

return WhatsAppWidget.load('widget-id').then(widget => widget.rendered).then(() => {
expect(article.querySelector('.hellotext--webchat-trigger').getAttribute('href'))
.toBe(`${clickUrl}?number=15551234567&text=Hola+desde+la+tienda`)
})
})
})

it('does not mount when the API returns no widget HTML', () => {
createStylesheet()
API.whatsappWidgets.get.mockResolvedValue(null)
Expand Down
2 changes: 1 addition & 1 deletion dist/hellotext.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/whatsapp.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ Hellotext.initialize('PUBLIC_BUSINESS_ID', {
| 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`.
The widget opens WhatsApp through `wa.me`. Its links lead there through Hellotext, which records the click and adds a reference to the prefilled message so the conversation can be traced back to the widget. Hellotext also counts the visitor's view of the widget with their session.
4 changes: 4 additions & 0 deletions lib/api/whatsapp_widgets.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ class WhatsAppWidgetsAPI {
}
static async get(id) {
const url = new URL(`${this.endpoint}/${id}`);

// The session counts the visitor's view of the widget. It doesn't change
// the response, so the server's cache leaves it out.
if (_hellotext.default.session) url.searchParams.append('session', _hellotext.default.session);
url.searchParams.append('locale', _core.Locale.toString());
url.searchParams.append('placement', _core.Configuration.whatsapp.placement);
this.appendWhatsAppOverrides(url);
Expand Down
4 changes: 4 additions & 0 deletions lib/api/whatsapp_widgets.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ class WhatsAppWidgetsAPI {
}
static async get(id) {
const url = new URL(`${this.endpoint}/${id}`);

// The session counts the visitor's view of the widget. It doesn't change
// the response, so the server's cache leaves it out.
if (Hellotext.session) url.searchParams.append('session', Hellotext.session);
url.searchParams.append('locale', Locale.toString());
url.searchParams.append('placement', Configuration.whatsapp.placement);
this.appendWhatsAppOverrides(url);
Expand Down
37 changes: 37 additions & 0 deletions lib/models/whatsapp_widget.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ exports.WhatsAppWidget = void 0;
var _core = require("../core");
var _api = _interopRequireDefault(require("../api"));
var _business = require("./business");
var _session = require("./session");
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
const CLICK_URL_ATTRIBUTE = 'data-hellotext--whatsapp-widget-click-url-value';
class WhatsAppWidget {
static async load(id) {
const widget = new WhatsAppWidget({
Expand All @@ -33,11 +35,46 @@ class WhatsAppWidget {
console.warn('Hellotext WhatsApp widget was not mounted because its stylesheet failed to load.');
return false;
}
this.linkThroughClickEndpoint();
container.appendChild(this.data.html);
this.markCoexistingWidgets();
this.mounted = true;
return true;
}

// The widget's links lead to WhatsApp through Hellotext's click endpoint,
// which records the click and adds the session's reference to the message.
// The server caches one widget for every visitor, so the links it renders
// can't carry the session. Each link is rebuilt here from the endpoint's URL,
// with the number and message the widget rendered, overrides included, and
// the session. Without a session the links stay as rendered: they still
// reach WhatsApp, with nothing recorded.
linkThroughClickEndpoint() {
const clickUrl = this.data.html.getAttribute(CLICK_URL_ATTRIBUTE);
const session = _session.Session.session;
if (!clickUrl || !session) return;
this.data.html.querySelectorAll('a[href]').forEach(link => {
const href = this.clickHref(clickUrl, link.getAttribute('href'), session);
if (href) link.setAttribute('href', href);
});
}

// Null for a link that doesn't lead to the click endpoint.
clickHref(clickUrl, href, session) {
try {
const endpoint = new URL(clickUrl);
const rendered = new URL(href, endpoint);
if (rendered.origin !== endpoint.origin || rendered.pathname !== endpoint.pathname) return null;
['number', 'text'].forEach(key => {
const value = rendered.searchParams.get(key);
if (value) endpoint.searchParams.set(key, value);
});
endpoint.searchParams.set('session', session);
return endpoint.toString();
} catch (_) {
return null;
}
}
get containerToAppendTo() {
try {
return document.querySelector(_core.Configuration.whatsapp.container);
Expand Down
37 changes: 37 additions & 0 deletions lib/models/whatsapp_widget.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Configuration } from '../core';
import API from '../api';
import { Business } from './business';
import { Session } from './session';
const CLICK_URL_ATTRIBUTE = 'data-hellotext--whatsapp-widget-click-url-value';
class WhatsAppWidget {
static async load(id) {
const widget = new WhatsAppWidget({
Expand All @@ -26,11 +28,46 @@ class WhatsAppWidget {
console.warn('Hellotext WhatsApp widget was not mounted because its stylesheet failed to load.');
return false;
}
this.linkThroughClickEndpoint();
container.appendChild(this.data.html);
this.markCoexistingWidgets();
this.mounted = true;
return true;
}

// The widget's links lead to WhatsApp through Hellotext's click endpoint,
// which records the click and adds the session's reference to the message.
// The server caches one widget for every visitor, so the links it renders
// can't carry the session. Each link is rebuilt here from the endpoint's URL,
// with the number and message the widget rendered, overrides included, and
// the session. Without a session the links stay as rendered: they still
// reach WhatsApp, with nothing recorded.
linkThroughClickEndpoint() {
const clickUrl = this.data.html.getAttribute(CLICK_URL_ATTRIBUTE);
const session = Session.session;
if (!clickUrl || !session) return;
this.data.html.querySelectorAll('a[href]').forEach(link => {
const href = this.clickHref(clickUrl, link.getAttribute('href'), session);
if (href) link.setAttribute('href', href);
});
}

// Null for a link that doesn't lead to the click endpoint.
clickHref(clickUrl, href, session) {
try {
const endpoint = new URL(clickUrl);
const rendered = new URL(href, endpoint);
if (rendered.origin !== endpoint.origin || rendered.pathname !== endpoint.pathname) return null;
['number', 'text'].forEach(key => {
const value = rendered.searchParams.get(key);
if (value) endpoint.searchParams.set(key, value);
});
endpoint.searchParams.set('session', session);
return endpoint.toString();
} catch (_) {
return null;
}
}
get containerToAppendTo() {
try {
return document.querySelector(Configuration.whatsapp.container);
Expand Down
3 changes: 3 additions & 0 deletions src/api/whatsapp_widgets.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ class WhatsAppWidgetsAPI {
static async get(id) {
const url = new URL(`${this.endpoint}/${id}`)

// The session counts the visitor's view of the widget. It doesn't change
// the response, so the server's cache leaves it out.
if (Hellotext.session) url.searchParams.append('session', Hellotext.session)
url.searchParams.append('locale', Locale.toString())
url.searchParams.append('placement', Configuration.whatsapp.placement)
this.appendWhatsAppOverrides(url)
Expand Down
42 changes: 42 additions & 0 deletions src/models/whatsapp_widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { Configuration } from '../core'

import API from '../api'
import { Business } from './business'
import { Session } from './session'

const CLICK_URL_ATTRIBUTE = 'data-hellotext--whatsapp-widget-click-url-value'

class WhatsAppWidget {
static async load(id) {
Expand Down Expand Up @@ -39,13 +42,52 @@ class WhatsAppWidget {
return false
}

this.linkThroughClickEndpoint()
container.appendChild(this.data.html)
this.markCoexistingWidgets()
this.mounted = true

return true
}

// The widget's links lead to WhatsApp through Hellotext's click endpoint,
// which records the click and adds the session's reference to the message.
// The server caches one widget for every visitor, so the links it renders
// can't carry the session. Each link is rebuilt here from the endpoint's URL,
// with the number and message the widget rendered, overrides included, and
// the session. Without a session the links stay as rendered: they still
// reach WhatsApp, with nothing recorded.
linkThroughClickEndpoint() {
const clickUrl = this.data.html.getAttribute(CLICK_URL_ATTRIBUTE)
const session = Session.session

if (!clickUrl || !session) return

this.data.html.querySelectorAll('a[href]').forEach(link => {
const href = this.clickHref(clickUrl, link.getAttribute('href'), session)
if (href) link.setAttribute('href', href)
})
}

// Null for a link that doesn't lead to the click endpoint.
clickHref(clickUrl, href, session) {
try {
const endpoint = new URL(clickUrl)
const rendered = new URL(href, endpoint)
if (rendered.origin !== endpoint.origin || rendered.pathname !== endpoint.pathname) return null

;['number', 'text'].forEach(key => {
const value = rendered.searchParams.get(key)
if (value) endpoint.searchParams.set(key, value)
})
endpoint.searchParams.set('session', session)

return endpoint.toString()
} catch (_) {
return null
}
}

get containerToAppendTo() {
try {
return document.querySelector(Configuration.whatsapp.container)
Expand Down
Loading