Skip to content

Commit dcd2980

Browse files
feat(freshchat,#82): regions IN/AU + event bridge + new methods + typed config
Vendor-doc audit fixes for Freshchat: Regions: new region shorthand "us"|"eu"|"in"|"au" mapped to the four documented Freshchat hosts (previously only US was the default and EU required passing `host:` manually; IN/AU were unreachable without constructing the host string). Methods (10): open, close (split from show/hide which now correctly map to widget.show/hide instead of open/close), setLocale, setTags, setFaqTags, setConfig, setBotVariables, setConversationProperties, trackPage, plus sync getters isOpen / isLoaded. pageView({path,locale}) is no longer a no-op — it forwards to widget.trackPage and widget.user.setLocale. Event bridge: new public on(event, handler) wraps 16 documented widget events (widgetLoaded/Opened/Closed/Destroyed, user:created/cleared/ statechange, message:sent/received, unreadCount:notify, dialog:opened/ closed, csat:received/updated, conversation:resolved, frame:statechange). unreadCount:notify also drives a new onUnreadCountChange() listener for parity with other providers. Typed config (FreshchatLoadOptions): siteId, locale, tags, faqTags, conversationReferenceId, open (open-on-load), eagerLoad — all forwarded into widget.init(). Existing externalId/restoreId kept. Bumps freshchat bundle budget 6KB → 9KB (raw 8209B; gzip ≈ 1/3). NOTE: This wraps the v1 SDK. The audit issue also flags a v2 migration (widget.authenticate, widget.user.getUUID, etc.) that requires a breaking change to the load() flow. That work is intentionally deferred to a separate issue. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d1ae76c commit dcd2980

3 files changed

Lines changed: 426 additions & 9 deletions

File tree

scripts/bundle-budget.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const BUDGETS = [
1313
{ glob: /^providers\/chatwoot\.mjs$/, max: 10240, label: "provider:chatwoot" },
1414
{ glob: /^providers\/crisp\.mjs$/, max: 10240, label: "provider:crisp" },
1515
{ glob: /^providers\/intercom\.mjs$/, max: 9216, label: "provider:intercom" },
16+
{ glob: /^providers\/freshchat\.mjs$/, max: 9216, label: "provider:freshchat" },
1617
{ glob: /^providers\/helpscout\.mjs$/, max: 7168, label: "provider:helpscout" },
1718
{ glob: /^providers\/hubspot\.mjs$/, max: 7168, label: "provider:hubspot" },
1819
{ glob: /^providers\/tawk\.mjs$/, max: 9216, label: "provider:tawk" },

src/providers/freshchat.ts

Lines changed: 188 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ interface FreshchatUser {
2020
}
2121

2222
interface FreshchatWidget {
23-
init(opts: { token: string; host: string; externalId?: string; restoreId?: string }): void
23+
init(opts: Record<string, unknown>): void
2424
user: {
2525
setProperties(props: Record<string, unknown>): void
2626
update(user: FreshchatUser): void
@@ -29,9 +29,20 @@ interface FreshchatWidget {
2929
setFirstName(name: string): void
3030
setLastName(name: string): void
3131
setPhone(phone: string): void
32+
setLocale?(locale: string): void
3233
}
3334
setExternalId(id: string): void
3435
setJWTAuthToken(token: string): void
36+
setConfig?(config: Record<string, unknown>): void
37+
setTags?(tags: string[]): void
38+
setFaqTags?(payload: { tags: string[]; filterType?: string }): void
39+
trackPage?(url: string, title?: string): void
40+
isOpen?(): boolean
41+
isLoaded?(): boolean
42+
conversation?: {
43+
setBotVariables?(vars: Record<string, unknown>): void
44+
setConversationProperties?(props: Record<string, unknown>): void
45+
}
3546
track?: (event: string, props?: Record<string, unknown>) => void
3647
show(): void
3748
hide(): void
@@ -55,23 +66,87 @@ function w(): FreshchatWindow {
5566
const queue = createQueue<FreshchatWidget>()
5667
const store = createIdentityStore()
5768
const lifecycle = createLifecycle()
69+
70+
export type FreshchatRegion = "us" | "eu" | "in" | "au"
71+
const REGION_HOSTS: Record<FreshchatRegion, string> = {
72+
us: "https://wchat.freshchat.com",
73+
eu: "https://wchat.eu.freshchat.com",
74+
in: "https://wchat.in.freshchat.com",
75+
au: "https://wchat.au.freshchat.com",
76+
}
77+
78+
export type FreshchatEventName =
79+
| "widgetLoaded"
80+
| "widgetOpened"
81+
| "widgetClosed"
82+
| "widgetDestroyed"
83+
| "userCreated"
84+
| "userCleared"
85+
| "userStateChange"
86+
| "messageSent"
87+
| "messageReceived"
88+
| "unreadCountNotify"
89+
| "dialogOpened"
90+
| "dialogClosed"
91+
| "csatReceived"
92+
| "csatUpdated"
93+
| "conversationResolved"
94+
| "frameStateChange"
95+
96+
const FRESHCHAT_EVENT_MAP: Record<string, FreshchatEventName> = {
97+
"widget:loaded": "widgetLoaded",
98+
"widget:opened": "widgetOpened",
99+
"widget:closed": "widgetClosed",
100+
"widget:destroyed": "widgetDestroyed",
101+
"user:created": "userCreated",
102+
"user:cleared": "userCleared",
103+
"user:statechange": "userStateChange",
104+
"message:sent": "messageSent",
105+
"message:received": "messageReceived",
106+
"unreadCount:notify": "unreadCountNotify",
107+
"dialog:opened": "dialogOpened",
108+
"dialog:closed": "dialogClosed",
109+
"csat:received": "csatReceived",
110+
"csat:updated": "csatUpdated",
111+
"conversation:resolved": "conversationResolved",
112+
"frame:statechange": "frameStateChange",
113+
}
114+
115+
const eventListeners = new Map<FreshchatEventName, Set<(payload?: unknown) => void>>()
116+
const unreadListeners = new Set<(count: number) => void>()
58117
let readyPromise: Promise<void> | undefined
59118
let readyResolve: (() => void) | undefined
60119
let currentToken: string | undefined
61120
let currentHost: string | undefined
62121

63122
export interface FreshchatLoadOptions extends LoadOptions {
64123
token: string
65-
/** e.g. wchat.freshchat.com (default) or wchat.eu.freshchat.com */
124+
/** Convenience for picking the regional host. */
125+
region?: FreshchatRegion
126+
/** Explicit host (overrides region). e.g. https://wchat.freshchat.com */
66127
host?: string
67128
externalId?: string
68129
restoreId?: string
130+
/** Multi-site separation under one Freshchat account. */
131+
siteId?: string
132+
/** Initial UI locale (e.g. "tr-TR"). */
133+
locale?: string
134+
/** Bot/topic tags applied at init. */
135+
tags?: string[]
136+
/** FAQ topic filter at init. */
137+
faqTags?: { tags: string[]; filterType?: string }
138+
/** Open a parallel conversation on the same topic. */
139+
conversationReferenceId?: string
140+
/** Open the widget panel on load. */
141+
open?: boolean
142+
/** Eagerly load the widget chrome before user interaction. */
143+
eagerLoad?: boolean
69144
}
70145

71146
export async function load(options: FreshchatLoadOptions): Promise<void> {
72147
if (!isBrowser()) return
73148
if (options.consent === false) return
74-
const host = options.host ?? "https://wchat.freshchat.com"
149+
const host = options.host ?? (options.region ? REGION_HOSTS[options.region] : REGION_HOSTS.us)
75150
const h = hashConfig({ token: options.token, host })
76151
if (lifecycle.state() === "ready" && lifecycle.configHash() === h) return
77152
if (lifecycle.configHash() && lifecycle.configHash() !== h) await destroy()
@@ -101,14 +176,42 @@ export async function load(options: FreshchatLoadOptions): Promise<void> {
101176
for (let i = 0; i < 80; i++) {
102177
const widget = w().fcWidget
103178
if (widget) {
104-
widget.init({
179+
const initPayload: Record<string, unknown> = {
105180
token: options.token,
106181
host,
107182
externalId: options.externalId,
108183
restoreId: options.restoreId,
109-
})
184+
}
185+
if (options.siteId !== undefined) initPayload["siteId"] = options.siteId
186+
if (options.locale !== undefined) initPayload["locale"] = options.locale
187+
if (options.tags !== undefined) initPayload["tags"] = options.tags
188+
if (options.faqTags !== undefined) initPayload["faqTags"] = options.faqTags
189+
if (options.conversationReferenceId !== undefined) {
190+
initPayload["conversationReferenceId"] = options.conversationReferenceId
191+
}
192+
if (options.open !== undefined) initPayload["open"] = options.open
193+
if (options.eagerLoad !== undefined) {
194+
initPayload["config"] = {
195+
...(initPayload["config"] as object),
196+
eagerLoad: options.eagerLoad,
197+
}
198+
}
199+
widget.init(initPayload)
110200
queue.ready(widget)
111-
widget.on("widget:loaded", () => readyResolve?.())
201+
// Wire all documented widget events to the typed emitter.
202+
for (const [vendorName, mapped] of Object.entries(FRESHCHAT_EVENT_MAP)) {
203+
widget.on(vendorName, (payload: unknown) => {
204+
const set = eventListeners.get(mapped)
205+
if (set) for (const l of set) l(payload)
206+
if (mapped === "widgetLoaded") readyResolve?.()
207+
if (mapped === "unreadCountNotify") {
208+
const count =
209+
(payload as { count?: number } | undefined)?.count ??
210+
(typeof payload === "number" ? payload : 0)
211+
for (const l of unreadListeners) l(count)
212+
}
213+
})
214+
}
112215
break
113216
}
114217
await new Promise((r) => setTimeout(r, 50))
@@ -149,20 +252,94 @@ export function track<T extends EventMetadata = EventMetadata>(
149252
return queue.enqueue((widget) => widget.track?.(event, metadata))
150253
}
151254

152-
export function pageView(_info?: { path?: string; locale?: string }): Promise<void> {
153-
return Promise.resolve()
255+
export function pageView(info?: { path?: string; locale?: string }): Promise<void> {
256+
if (!isBrowser()) return Promise.resolve()
257+
return queue.enqueue((widget) => {
258+
if (info?.path && widget.trackPage) widget.trackPage(info.path)
259+
if (info?.locale) widget.user.setLocale?.(info.locale)
260+
})
154261
}
155262

156263
export function show(): Promise<void> {
157264
if (!isBrowser()) return Promise.resolve()
158-
return queue.enqueue((widget) => widget.open())
265+
return queue.enqueue((widget) => widget.show())
159266
}
160267

161268
export function hide(): Promise<void> {
269+
if (!isBrowser()) return Promise.resolve()
270+
return queue.enqueue((widget) => widget.hide())
271+
}
272+
273+
export function open(opts?: { name?: string }): Promise<void> {
274+
if (!isBrowser()) return Promise.resolve()
275+
return queue.enqueue((widget) => widget.open(opts))
276+
}
277+
278+
export function close(): Promise<void> {
162279
if (!isBrowser()) return Promise.resolve()
163280
return queue.enqueue((widget) => widget.close())
164281
}
165282

283+
export function setLocale(locale: string): Promise<void> {
284+
if (!isBrowser()) return Promise.resolve()
285+
return queue.enqueue((widget) => widget.user.setLocale?.(locale))
286+
}
287+
288+
export function setTags(tags: string[]): Promise<void> {
289+
if (!isBrowser()) return Promise.resolve()
290+
return queue.enqueue((widget) => widget.setTags?.(tags))
291+
}
292+
293+
export function setFaqTags(payload: { tags: string[]; filterType?: string }): Promise<void> {
294+
if (!isBrowser()) return Promise.resolve()
295+
return queue.enqueue((widget) => widget.setFaqTags?.(payload))
296+
}
297+
298+
export function setConfig(config: Record<string, unknown>): Promise<void> {
299+
if (!isBrowser()) return Promise.resolve()
300+
return queue.enqueue((widget) => widget.setConfig?.(config))
301+
}
302+
303+
export function setBotVariables(vars: Record<string, unknown>): Promise<void> {
304+
if (!isBrowser()) return Promise.resolve()
305+
return queue.enqueue((widget) => widget.conversation?.setBotVariables?.(vars))
306+
}
307+
308+
export function setConversationProperties(props: Record<string, unknown>): Promise<void> {
309+
if (!isBrowser()) return Promise.resolve()
310+
return queue.enqueue((widget) => widget.conversation?.setConversationProperties?.(props))
311+
}
312+
313+
export function trackPage(url: string, title?: string): Promise<void> {
314+
if (!isBrowser()) return Promise.resolve()
315+
return queue.enqueue((widget) => widget.trackPage?.(url, title))
316+
}
317+
318+
export function isOpen(): boolean | undefined {
319+
if (!isBrowser()) return undefined
320+
return w().fcWidget?.isOpen?.()
321+
}
322+
323+
export function isLoaded(): boolean | undefined {
324+
if (!isBrowser()) return undefined
325+
return w().fcWidget?.isLoaded?.()
326+
}
327+
328+
export function on(event: FreshchatEventName, listener: (payload?: unknown) => void): () => void {
329+
let set = eventListeners.get(event)
330+
if (!set) {
331+
set = new Set()
332+
eventListeners.set(event, set)
333+
}
334+
set.add(listener)
335+
return () => set?.delete(listener)
336+
}
337+
338+
export function onUnreadCountChange(listener: (count: number) => void): () => void {
339+
unreadListeners.add(listener)
340+
return () => unreadListeners.delete(listener)
341+
}
342+
166343
export function shutdown(): Promise<void> {
167344
if (!isBrowser()) return Promise.resolve()
168345
return queue
@@ -185,6 +362,8 @@ export async function destroy(): Promise<void> {
185362
Reflect.deleteProperty(g, "fcSettings")
186363
queue.reset()
187364
store.reset()
365+
eventListeners.clear()
366+
unreadListeners.clear()
188367
currentToken = undefined
189368
currentHost = undefined
190369
readyPromise = undefined

0 commit comments

Comments
 (0)