Skip to content

Commit 733323a

Browse files
feat(liveagent,#88): visitor methods + button callbacks + setVisitorLocation
Vendor-doc audit fixes for LiveAgent: Methods (6): addUserDetail (single-key user property update), addTicketField, clearAllUserDetails, setVisitorLocation, createForm (offline contact-form variant of createButton), plus sync hasOpenedWidget() reading from LiveAgent.instance. pageView({path}) is no longer a no-op — it forwards to setVisitorLocation so SPA route changes show up in the agent panel. Event bridge: new public on(event, handler) wraps the documented button-level callbacks via the button instance returned from createButton — chatStarted (button.onClick), chatEnded (onCloseFunction_), online (onOnline), offline (onOffline). Replaces the dead top-level LiveAgent.onChatStarted / onChatEnded type fields. shutdown() now actually clears user details (clearAllUserDetails) so identity reset means something on the LiveAgent side. LiveAgentLoadOptions: disableOnlineVisitorsTracking flag fired before createButton (per vendor docs ordering). identify() also forwards no-arg setUserDetails-style optionals. Bundle within existing 6KB budget (raw 5445B; gzip ≈ 1/3). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 77c2eda commit 733323a

2 files changed

Lines changed: 255 additions & 10 deletions

File tree

src/providers/liveagent.ts

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,29 @@ import type {
1111
LoadOptions,
1212
} from "../_types.ts"
1313

14+
interface LiveAgentButton {
15+
onOnline?: () => void
16+
onOffline?: () => void
17+
onClick?: () => void
18+
onCloseFunction_?: () => void
19+
}
20+
21+
interface LiveAgentInstance {
22+
hasOpenedWidget?(): boolean
23+
}
24+
1425
interface LiveAgentAPI {
15-
setUserDetails(email: string, firstName: string, lastName: string, phone: string): void
26+
setUserDetails(email?: string, firstName?: string, lastName?: string, phone?: string): void
27+
addUserDetail?(key: "email" | "firstName" | "lastName" | "phone", value: string): void
1628
addContactField(field: string, value: unknown): void
17-
createButton(buttonId: string, container?: unknown): void
29+
addTicketField?(key: string, value: unknown): void
30+
clearAllUserDetails?(): void
31+
setVisitorLocation?(url: string): void
32+
disableOnlineVisitorsTracking?(): void
33+
createButton(buttonId: string, container?: unknown): LiveAgentButton | undefined
34+
createForm?(formId: string, container?: unknown): LiveAgentButton | undefined
1835
hideButton?(buttonId: string): void
19-
onChatStarted?: () => void
20-
onChatEnded?: () => void
36+
instance?: LiveAgentInstance
2137
}
2238

2339
interface LiveAgentWindow {
@@ -34,12 +50,17 @@ const lifecycle = createLifecycle()
3450
let currentSubdomain: string | undefined
3551
let currentButtonId: string | undefined
3652

53+
export type LiveAgentEventName = "online" | "offline" | "chatStarted" | "chatEnded"
54+
const eventListeners = new Map<LiveAgentEventName, Set<() => void>>()
55+
3756
export interface LiveAgentLoadOptions extends LoadOptions {
3857
/** e.g. "yourcompany" — yourcompany.ladesk.com */
3958
accountSubdomain: string
4059
buttonId: string
4160
/** Self-hosted base URL override (e.g. "https://support.example.com"). */
4261
selfHostedBaseUrl?: string
62+
/** When true, calls LiveAgent.disableOnlineVisitorsTracking() before createButton. */
63+
disableOnlineVisitorsTracking?: boolean
4364
}
4465

4566
export async function load(options: LiveAgentLoadOptions): Promise<void> {
@@ -77,7 +98,24 @@ export async function load(options: LiveAgentLoadOptions): Promise<void> {
7798
for (let i = 0; i < 80; i++) {
7899
const api = w().LiveAgent
79100
if (api) {
80-
api.createButton(options.buttonId)
101+
if (options.disableOnlineVisitorsTracking) api.disableOnlineVisitorsTracking?.()
102+
const button = api.createButton(options.buttonId)
103+
if (button) {
104+
// Vendor pattern: monkey-patch button.onClick / onCloseFunction_ for chat
105+
// start/end, and onOnline / onOffline for agent availability.
106+
button.onClick = () => {
107+
for (const l of eventListeners.get("chatStarted") ?? []) l()
108+
}
109+
button.onCloseFunction_ = () => {
110+
for (const l of eventListeners.get("chatEnded") ?? []) l()
111+
}
112+
button.onOnline = () => {
113+
for (const l of eventListeners.get("online") ?? []) l()
114+
}
115+
button.onOffline = () => {
116+
for (const l of eventListeners.get("offline") ?? []) l()
117+
}
118+
}
81119
queue.ready(api)
82120
break
83121
}
@@ -113,8 +151,54 @@ export function track<T extends EventMetadata = EventMetadata>(
113151
})
114152
}
115153

116-
export function pageView(_info?: { path?: string; locale?: string }): Promise<void> {
117-
return Promise.resolve()
154+
export function pageView(info?: { path?: string; locale?: string }): Promise<void> {
155+
if (!isBrowser()) return Promise.resolve()
156+
return queue.enqueue((api) => {
157+
if (info?.path && api.setVisitorLocation) api.setVisitorLocation(info.path)
158+
})
159+
}
160+
161+
export function addUserDetail(
162+
key: "email" | "firstName" | "lastName" | "phone",
163+
value: string,
164+
): Promise<void> {
165+
if (!isBrowser()) return Promise.resolve()
166+
return queue.enqueue((api) => api.addUserDetail?.(key, value))
167+
}
168+
169+
export function addTicketField(key: string, value: unknown): Promise<void> {
170+
if (!isBrowser()) return Promise.resolve()
171+
return queue.enqueue((api) => api.addTicketField?.(key, value))
172+
}
173+
174+
export function clearAllUserDetails(): Promise<void> {
175+
if (!isBrowser()) return Promise.resolve()
176+
return queue.enqueue((api) => api.clearAllUserDetails?.())
177+
}
178+
179+
export function setVisitorLocation(url: string): Promise<void> {
180+
if (!isBrowser()) return Promise.resolve()
181+
return queue.enqueue((api) => api.setVisitorLocation?.(url))
182+
}
183+
184+
export function createForm(formId: string, container?: unknown): Promise<void> {
185+
if (!isBrowser()) return Promise.resolve()
186+
return queue.enqueue((api) => api.createForm?.(formId, container))
187+
}
188+
189+
export function hasOpenedWidget(): boolean | undefined {
190+
if (!isBrowser()) return undefined
191+
return w().LiveAgent?.instance?.hasOpenedWidget?.()
192+
}
193+
194+
export function on(event: LiveAgentEventName, listener: () => void): () => void {
195+
let set = eventListeners.get(event)
196+
if (!set) {
197+
set = new Set()
198+
eventListeners.set(event, set)
199+
}
200+
set.add(listener)
201+
return () => set?.delete(listener)
118202
}
119203

120204
export function show(): Promise<void> {
@@ -133,9 +217,12 @@ export function hide(): Promise<void> {
133217

134218
export function shutdown(): Promise<void> {
135219
if (!isBrowser()) return Promise.resolve()
136-
store.reset()
137-
lifecycle.transition("shutdown")
138-
return Promise.resolve()
220+
return queue
221+
.enqueue((api) => api.clearAllUserDetails?.())
222+
.then(() => {
223+
store.reset()
224+
lifecycle.transition("shutdown")
225+
})
139226
}
140227

141228
export async function destroy(): Promise<void> {
@@ -146,6 +233,7 @@ export async function destroy(): Promise<void> {
146233
Reflect.deleteProperty(g, "LiveAgent")
147234
queue.reset()
148235
store.reset()
236+
eventListeners.clear()
149237
currentSubdomain = undefined
150238
currentButtonId = undefined
151239
lifecycle.clearConfigHash()

test/liveagent.browser.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// @vitest-environment jsdom
2+
import { beforeEach, describe, expect, it, vi } from "vitest"
3+
4+
interface CallLog {
5+
name: string
6+
args: unknown[]
7+
}
8+
9+
interface FakeButton {
10+
onClick?: () => void
11+
onCloseFunction_?: () => void
12+
onOnline?: () => void
13+
onOffline?: () => void
14+
}
15+
16+
async function bootLiveAgent(
17+
options?: Partial<import("../src/providers/liveagent.ts").LiveAgentLoadOptions>,
18+
): Promise<{
19+
liveagent: typeof import("../src/providers/liveagent.ts")
20+
log: CallLog[]
21+
button: FakeButton
22+
api: Record<string, unknown>
23+
}> {
24+
const liveagent = await import("../src/providers/liveagent.ts")
25+
const log: CallLog[] = []
26+
const rec =
27+
(name: string) =>
28+
(...args: unknown[]) =>
29+
log.push({ name, args })
30+
const button: FakeButton = {}
31+
const api: Record<string, unknown> = {
32+
setUserDetails: rec("setUserDetails"),
33+
addUserDetail: rec("addUserDetail"),
34+
addContactField: rec("addContactField"),
35+
addTicketField: rec("addTicketField"),
36+
clearAllUserDetails: rec("clearAllUserDetails"),
37+
setVisitorLocation: rec("setVisitorLocation"),
38+
disableOnlineVisitorsTracking: rec("disableOnlineVisitorsTracking"),
39+
createButton: (...args: unknown[]) => {
40+
log.push({ name: "createButton", args })
41+
return button
42+
},
43+
createForm: rec("createForm"),
44+
hideButton: rec("hideButton"),
45+
instance: { hasOpenedWidget: () => true },
46+
}
47+
// biome-ignore lint/suspicious/noExplicitAny: test shim
48+
;(globalThis as any).LiveAgent = api
49+
const loadPromise = liveagent.load({
50+
accountSubdomain: "yourcompany",
51+
buttonId: "btn_xyz",
52+
...options,
53+
})
54+
await new Promise((r) => setTimeout(r, 0))
55+
const script = document.getElementById("ahize-liveagent") as HTMLScriptElement
56+
expect(script).toBeTruthy()
57+
script.dispatchEvent(new Event("load"))
58+
await new Promise((r) => setTimeout(r, 60))
59+
await loadPromise
60+
return { liveagent, log, button, api }
61+
}
62+
63+
describe("liveagent (browser) — vendor-doc audit fixes (#88)", () => {
64+
beforeEach(() => {
65+
vi.resetModules()
66+
// biome-ignore lint/suspicious/noExplicitAny: test cleanup
67+
delete (globalThis as any).LiveAgent
68+
const scripts = document.querySelectorAll("script")
69+
for (let i = 0; i < scripts.length; i++) {
70+
;(scripts[i] as { remove(): void } | undefined)?.remove()
71+
}
72+
})
73+
74+
it("disableOnlineVisitorsTracking: true is called before createButton", async () => {
75+
const { liveagent, log } = await bootLiveAgent({ disableOnlineVisitorsTracking: true })
76+
const disableIdx = log.findIndex((c) => c.name === "disableOnlineVisitorsTracking")
77+
const createIdx = log.findIndex((c) => c.name === "createButton")
78+
expect(disableIdx).toBeGreaterThanOrEqual(0)
79+
expect(createIdx).toBeGreaterThan(disableIdx)
80+
await liveagent.destroy()
81+
})
82+
83+
it("addUserDetail/addTicketField/clearAllUserDetails/setVisitorLocation/createForm forward", async () => {
84+
const { liveagent, log } = await bootLiveAgent()
85+
log.length = 0
86+
await liveagent.addUserDetail("email", "u@example.com")
87+
await liveagent.addTicketField("priority", "high")
88+
await liveagent.clearAllUserDetails()
89+
await liveagent.setVisitorLocation("/checkout")
90+
await liveagent.createForm("form_1")
91+
const names = log.map((c) => c.name)
92+
for (const m of [
93+
"addUserDetail",
94+
"addTicketField",
95+
"clearAllUserDetails",
96+
"setVisitorLocation",
97+
"createForm",
98+
]) {
99+
expect(names).toContain(m)
100+
}
101+
await liveagent.destroy()
102+
})
103+
104+
it("pageView({path}) calls setVisitorLocation", async () => {
105+
const { liveagent, log } = await bootLiveAgent()
106+
log.length = 0
107+
await liveagent.pageView({ path: "/products/123" })
108+
const call = log.find((c) => c.name === "setVisitorLocation")
109+
expect(call?.args).toEqual(["/products/123"])
110+
await liveagent.destroy()
111+
})
112+
113+
it("hasOpenedWidget reads from LiveAgent.instance", async () => {
114+
const { liveagent } = await bootLiveAgent()
115+
expect(liveagent.hasOpenedWidget()).toBe(true)
116+
await liveagent.destroy()
117+
})
118+
119+
it("on(chatStarted/chatEnded/online/offline) bridges button callbacks", async () => {
120+
const { liveagent, button } = await bootLiveAgent()
121+
let started = 0
122+
let ended = 0
123+
let online = 0
124+
let offline = 0
125+
liveagent.on("chatStarted", () => {
126+
started++
127+
})
128+
liveagent.on("chatEnded", () => {
129+
ended++
130+
})
131+
liveagent.on("online", () => {
132+
online++
133+
})
134+
liveagent.on("offline", () => {
135+
offline++
136+
})
137+
138+
button.onClick?.()
139+
button.onCloseFunction_?.()
140+
button.onOnline?.()
141+
button.onOffline?.()
142+
143+
expect(started).toBe(1)
144+
expect(ended).toBe(1)
145+
expect(online).toBe(1)
146+
expect(offline).toBe(1)
147+
await liveagent.destroy()
148+
})
149+
150+
it("shutdown() calls clearAllUserDetails", async () => {
151+
const { liveagent, log } = await bootLiveAgent()
152+
log.length = 0
153+
await liveagent.shutdown()
154+
expect(log.find((c) => c.name === "clearAllUserDetails")).toBeDefined()
155+
await liveagent.destroy()
156+
})
157+
})

0 commit comments

Comments
 (0)