close
Skip to content

Commit 77c2eda

Browse files
feat(livechat,#89): typed __lc init config, methods, sync getters, events
Vendor-doc audit fixes for LiveChat (Text): Methods (5): maximize(messageDraft?), minimize, hideGreeting, triggerSalesTracker (purchase analytics), setSessionVariables (replace semantics, distinct from update_session_variables which is what identify() and track() still use). Sync getters: get<T>(method), plus convenience getState / getCustomerData / getChatData reading from widget.get(...). Event bridge: new public on(event, handler) wraps 10 documented LiveChatWidget events (ready, availability_changed, visibility_changed, customer_status_changed, new_event, form_submitted, rating_submitted, greeting_displayed, greeting_hidden, rich_message_button_clicked). Typed __lc init config (LiveChatLoadOptions): group, visibility (initial maximized/minimized/hidden), sessionVariables, customerName, customerEmail, chatBetweenGroups, asyncInit. Also writes product_name="ahize" alongside the existing integration_name. Bundle within existing 6KB budget (raw 5691B; gzip ≈ 1/3). NOTE: track() still maps to update_session_variables (no first-class event API in the Chat Widget). Audit also flags custom_identity_provider / JWT secure session — that's a Developers Console + server-side concern more than a wrapper change, deferred. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7f82fd8 commit 77c2eda

File tree

2 files changed

+289
-3
lines changed

2 files changed

+289
-3
lines changed

‎src/providers/livechat.ts‎

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,25 @@ interface LiveChatWidget {
1616
on(event: string, cb: (payload: unknown) => void): void
1717
off(event: string, cb: (payload: unknown) => void): void
1818
get<T = unknown>(method: string): T
19+
init?(): void
20+
}
21+
22+
interface LiveChatLcConfig {
23+
license: number
24+
integration_name?: string
25+
product_name?: string
26+
group?: number
27+
visibility?: "maximized" | "minimized" | "hidden"
28+
sessionVariables?: Record<string, string>
29+
customerName?: string
30+
customerEmail?: string
31+
chatBetweenGroups?: boolean
32+
asyncInit?: boolean
33+
[key: string]: unknown
1934
}
2035

2136
interface LiveChatWindow {
22-
__lc?: { license: number; integration_name?: string; product_name?: string }
37+
__lc?: LiveChatLcConfig
2338
LiveChatWidget?: LiveChatWidget
2439
}
2540

@@ -30,11 +45,62 @@ function w(): LiveChatWindow {
3045
const queue = createQueue<LiveChatWidget>()
3146
const store = createIdentityStore()
3247
const lifecycle = createLifecycle()
48+
49+
export type LiveChatEventName =
50+
| "ready"
51+
| "availabilityChanged"
52+
| "visibilityChanged"
53+
| "customerStatusChanged"
54+
| "newEvent"
55+
| "formSubmitted"
56+
| "ratingSubmitted"
57+
| "greetingDisplayed"
58+
| "greetingHidden"
59+
| "richMessageButtonClicked"
60+
61+
const LIVECHAT_EVENT_MAP: Record<LiveChatEventName, string> = {
62+
ready: "ready",
63+
availabilityChanged: "availability_changed",
64+
visibilityChanged: "visibility_changed",
65+
customerStatusChanged: "customer_status_changed",
66+
newEvent: "new_event",
67+
formSubmitted: "form_submitted",
68+
ratingSubmitted: "rating_submitted",
69+
greetingDisplayed: "greeting_displayed",
70+
greetingHidden: "greeting_hidden",
71+
richMessageButtonClicked: "rich_message_button_clicked",
72+
}
73+
74+
const eventListeners = new Map<LiveChatEventName, Set<(payload?: unknown) => void>>()
3375
let readyPromise: Promise<void> | undefined
3476
let readyResolve: (() => void) | undefined
3577

78+
const TYPED_LC_KEYS = [
79+
"group",
80+
"visibility",
81+
"sessionVariables",
82+
"customerName",
83+
"customerEmail",
84+
"chatBetweenGroups",
85+
"asyncInit",
86+
] as const
87+
3688
export interface LiveChatLoadOptions extends LoadOptions {
3789
license: number
90+
/** Route to a specific agent group. */
91+
group?: number
92+
/** Initial widget visibility. */
93+
visibility?: "maximized" | "minimized" | "hidden"
94+
/** Session variables set at init time. */
95+
sessionVariables?: Record<string, string>
96+
/** Pre-fill customer name. */
97+
customerName?: string
98+
/** Pre-fill customer email. */
99+
customerEmail?: string
100+
/** Allow customer to chat across multiple groups. */
101+
chatBetweenGroups?: boolean
102+
/** Defer LiveChatWidget.init() until manually called via the SDK. */
103+
asyncInit?: boolean
38104
}
39105

40106
export async function load(options: LiveChatLoadOptions): Promise<void> {
@@ -51,7 +117,16 @@ export async function load(options: LiveChatLoadOptions): Promise<void> {
51117
})
52118
await waitForDefer(options.defer ?? "immediate")
53119

54-
w().__lc = { license: options.license, integration_name: "ahize" }
120+
const lc: LiveChatLcConfig = {
121+
license: options.license,
122+
integration_name: "ahize",
123+
product_name: "ahize",
124+
}
125+
for (const key of TYPED_LC_KEYS) {
126+
const v = options[key]
127+
if (v !== undefined) (lc as Record<string, unknown>)[key] = v
128+
}
129+
w().__lc = lc
55130

56131
try {
57132
await injectScript({
@@ -71,7 +146,16 @@ export async function load(options: LiveChatLoadOptions): Promise<void> {
71146
const widget = w().LiveChatWidget
72147
if (widget) {
73148
queue.ready(widget)
74-
widget.on("ready", () => readyResolve?.())
149+
// Wire every documented widget event to the typed emitter.
150+
for (const [mapped, vendor] of Object.entries(LIVECHAT_EVENT_MAP) as Array<
151+
[LiveChatEventName, string]
152+
>) {
153+
widget.on(vendor, (payload: unknown) => {
154+
const set = eventListeners.get(mapped)
155+
if (set) for (const l of set) l(payload)
156+
if (mapped === "ready") readyResolve?.()
157+
})
158+
}
75159
break
76160
}
77161
await new Promise((r) => setTimeout(r, 50))
@@ -119,6 +203,64 @@ export function hide(): Promise<void> {
119203
return queue.enqueue((widget) => widget.call("hide"))
120204
}
121205

206+
export function maximize(messageDraft?: string): Promise<void> {
207+
if (!isBrowser()) return Promise.resolve()
208+
return queue.enqueue((widget) =>
209+
messageDraft === undefined ? widget.call("maximize") : widget.call("maximize", messageDraft),
210+
)
211+
}
212+
213+
export function minimize(): Promise<void> {
214+
if (!isBrowser()) return Promise.resolve()
215+
return queue.enqueue((widget) => widget.call("minimize"))
216+
}
217+
218+
export function hideGreeting(): Promise<void> {
219+
if (!isBrowser()) return Promise.resolve()
220+
return queue.enqueue((widget) => widget.call("hide_greeting"))
221+
}
222+
223+
export function triggerSalesTracker(args: {
224+
trackerId: number
225+
orderPrice: number
226+
orderId?: string
227+
}): Promise<void> {
228+
if (!isBrowser()) return Promise.resolve()
229+
return queue.enqueue((widget) => widget.call("trigger_sales_tracker", args))
230+
}
231+
232+
export function setSessionVariables(vars: Record<string, string>): Promise<void> {
233+
if (!isBrowser()) return Promise.resolve()
234+
return queue.enqueue((widget) => widget.call("set_session_variables", vars))
235+
}
236+
237+
export function get<T = unknown>(method: string): T | undefined {
238+
if (!isBrowser()) return undefined
239+
return w().LiveChatWidget?.get<T>(method)
240+
}
241+
242+
export function getState(): unknown {
243+
return get("state")
244+
}
245+
246+
export function getCustomerData(): unknown {
247+
return get("customer_data")
248+
}
249+
250+
export function getChatData(): unknown {
251+
return get("chat_data")
252+
}
253+
254+
export function on(event: LiveChatEventName, listener: (payload?: unknown) => void): () => void {
255+
let set = eventListeners.get(event)
256+
if (!set) {
257+
set = new Set()
258+
eventListeners.set(event, set)
259+
}
260+
set.add(listener)
261+
return () => set?.delete(listener)
262+
}
263+
122264
export function shutdown(): Promise<void> {
123265
if (!isBrowser()) return Promise.resolve()
124266
return queue
@@ -138,6 +280,7 @@ export async function destroy(): Promise<void> {
138280
Reflect.deleteProperty(g, "LiveChatWidget")
139281
queue.reset()
140282
store.reset()
283+
eventListeners.clear()
141284
readyPromise = undefined
142285
readyResolve = undefined
143286
lifecycle.clearConfigHash()

‎test/livechat.browser.test.ts‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// @vitest-environment jsdom
2+
import { beforeEach, describe, expect, it, vi } from "vitest"
3+
4+
interface CallLog {
5+
name: "call" | "on"
6+
args: unknown[]
7+
}
8+
9+
async function bootLiveChat(
10+
options?: Partial<import("../src/providers/livechat.ts").LiveChatLoadOptions>,
11+
): Promise<{
12+
livechat: typeof import("../src/providers/livechat.ts")
13+
log: CallLog[]
14+
fire: (event: string, payload?: unknown) => void
15+
getReturns: Map<string, unknown>
16+
}> {
17+
const livechat = await import("../src/providers/livechat.ts")
18+
const log: CallLog[] = []
19+
const handlers = new Map<string, Array<(p?: unknown) => void>>()
20+
const getReturns = new Map<string, unknown>()
21+
const widget = {
22+
call: (...args: unknown[]) => log.push({ name: "call", args }),
23+
on: (event: string, cb: (p?: unknown) => void) => {
24+
log.push({ name: "on", args: [event] })
25+
const arr = handlers.get(event) ?? []
26+
arr.push(cb)
27+
handlers.set(event, arr)
28+
},
29+
off: () => {},
30+
get: <T>(method: string) => getReturns.get(method) as T,
31+
}
32+
// biome-ignore lint/suspicious/noExplicitAny: test shim
33+
;(globalThis as any).LiveChatWidget = widget
34+
const loadPromise = livechat.load({ license: 12345, ...options })
35+
await new Promise((r) => setTimeout(r, 0))
36+
const script = document.getElementById("ahize-livechat") as HTMLScriptElement
37+
expect(script).toBeTruthy()
38+
script.dispatchEvent(new Event("load"))
39+
await new Promise((r) => setTimeout(r, 60))
40+
// Fire ready so the wrapper resolves.
41+
for (const cb of handlers.get("ready") ?? []) cb()
42+
await loadPromise
43+
const fire = (event: string, payload?: unknown) => {
44+
for (const cb of handlers.get(event) ?? []) cb(payload)
45+
}
46+
return { livechat, log, fire, getReturns }
47+
}
48+
49+
describe("livechat (browser) — vendor-doc audit fixes (#89)", () => {
50+
beforeEach(() => {
51+
vi.resetModules()
52+
// biome-ignore lint/suspicious/noExplicitAny: test cleanup
53+
delete (globalThis as any).LiveChatWidget
54+
// biome-ignore lint/suspicious/noExplicitAny: test cleanup
55+
delete (globalThis as any).__lc
56+
const scripts = document.querySelectorAll("script")
57+
for (let i = 0; i < scripts.length; i++) {
58+
;(scripts[i] as { remove(): void } | undefined)?.remove()
59+
}
60+
})
61+
62+
it("typed __lc keys (group/visibility/etc) propagate", async () => {
63+
const { livechat } = await bootLiveChat({
64+
group: 7,
65+
visibility: "minimized",
66+
sessionVariables: { plan: "pro" },
67+
customerName: "Mehmet",
68+
customerEmail: "m@example.com",
69+
chatBetweenGroups: true,
70+
asyncInit: true,
71+
})
72+
// biome-ignore lint/suspicious/noExplicitAny: test inspect
73+
const lc = (globalThis as any).__lc as Record<string, unknown>
74+
expect(lc).toMatchObject({
75+
group: 7,
76+
visibility: "minimized",
77+
sessionVariables: { plan: "pro" },
78+
customerName: "Mehmet",
79+
customerEmail: "m@example.com",
80+
chatBetweenGroups: true,
81+
asyncInit: true,
82+
product_name: "ahize",
83+
})
84+
await livechat.destroy()
85+
})
86+
87+
it("maximize(messageDraft)/minimize/hideGreeting/triggerSalesTracker/setSessionVariables forward", async () => {
88+
const { livechat, log } = await bootLiveChat()
89+
log.length = 0
90+
await livechat.maximize("draft text")
91+
await livechat.minimize()
92+
await livechat.hideGreeting()
93+
await livechat.triggerSalesTracker({ trackerId: 1, orderPrice: 99, orderId: "ord_1" })
94+
await livechat.setSessionVariables({ utm: "google" })
95+
96+
const calls = log.filter((c) => c.name === "call").map((c) => c.args)
97+
expect(calls).toContainEqual(["maximize", "draft text"])
98+
expect(calls).toContainEqual(["minimize"])
99+
expect(calls).toContainEqual(["hide_greeting"])
100+
expect(calls).toContainEqual([
101+
"trigger_sales_tracker",
102+
{ trackerId: 1, orderPrice: 99, orderId: "ord_1" },
103+
])
104+
expect(calls).toContainEqual(["set_session_variables", { utm: "google" }])
105+
await livechat.destroy()
106+
})
107+
108+
it("get() / getState / getCustomerData / getChatData read from widget.get", async () => {
109+
const { livechat, getReturns } = await bootLiveChat()
110+
getReturns.set("state", { availability: "online" })
111+
getReturns.set("customer_data", { id: "u1" })
112+
getReturns.set("chat_data", { chatId: "c1" })
113+
expect(livechat.getState()).toEqual({ availability: "online" })
114+
expect(livechat.getCustomerData()).toEqual({ id: "u1" })
115+
expect(livechat.getChatData()).toEqual({ chatId: "c1" })
116+
await livechat.destroy()
117+
})
118+
119+
it("on() bridges availability/visibility/customer_status/new_event/etc", async () => {
120+
const { livechat, fire } = await bootLiveChat()
121+
let availability: unknown
122+
let lastForm: unknown
123+
let ratingCount = 0
124+
livechat.on("availabilityChanged", (p) => {
125+
availability = p
126+
})
127+
livechat.on("formSubmitted", (p) => {
128+
lastForm = p
129+
})
130+
livechat.on("ratingSubmitted", () => {
131+
ratingCount++
132+
})
133+
134+
fire("availability_changed", { availability: "online" })
135+
fire("form_submitted", { type: "prechat" })
136+
fire("rating_submitted", "good")
137+
138+
expect(availability).toEqual({ availability: "online" })
139+
expect(lastForm).toEqual({ type: "prechat" })
140+
expect(ratingCount).toBe(1)
141+
await livechat.destroy()
142+
})
143+
})

0 commit comments

Comments
 (0)