Skip to content

Commit 1c4e4b6

Browse files
feat(#19,#20,#21,#49-57): cross-cutting helpers + zendesk-classic + zendesk login error hook
- ahize/zendesk-classic: separate provider for the legacy Web Widget (zE('webWidget', ...)) — different snippet, different command surface, kept apart so types don't pretend cross-compat (#19) - zendesk: onLoginError(listener) callback fires from both errorCallback and getToken rejection (#20) - destroy() on every provider already covered unmount/remount safety (#21) Cross-cutting: - ahize/capabilities (#49,#50,#51,#52,#53,#54): per-provider feature matrix (hmac/jwt/callback/trackEvents/unreadCount/prefill/setLocale/setTheme/selfHosted/regions). capabilities(provider) returns full record; supports(provider, feature) for boolean checks. - ahize/diagnostics (#57): diagnose(provider, config) probes the CDN URL with a HEAD request and surfaces 400/403/404 as actionable hints; CORS-blocked probes return a soft warning. - BaseLoadOptions adds zIndex (default 2147482647 = max-1000 so app modals can sit above) (#50). - Symmetric APIs (#55) and raw escape hatch (#56) — every provider already exports getIdentity/onIdentityChange/isReady/state pairs and providers expose their underlying API via the queue<T> generic (consumers can create their own raw bridge with ahize/intercom's exported queue). Closes #19 #20 #21 #49 #50 #51 #52 #53 #54 #55 #56 #57 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5805250 commit 1c4e4b6

8 files changed

Lines changed: 389 additions & 1 deletion

File tree

package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@
6464
"types": "./dist/providers/zendesk.d.mts",
6565
"default": "./dist/providers/zendesk.mjs"
6666
},
67+
"./zendesk-classic": {
68+
"types": "./dist/providers/zendesk-classic.d.mts",
69+
"default": "./dist/providers/zendesk-classic.mjs"
70+
},
6771
"./hubspot": {
6872
"types": "./dist/providers/hubspot.d.mts",
6973
"default": "./dist/providers/hubspot.mjs"
@@ -131,6 +135,14 @@
131135
"./facade": {
132136
"types": "./dist/facade.d.mts",
133137
"default": "./dist/facade.mjs"
138+
},
139+
"./capabilities": {
140+
"types": "./dist/capabilities.d.mts",
141+
"default": "./dist/capabilities.mjs"
142+
},
143+
"./diagnostics": {
144+
"types": "./dist/diagnostics.d.mts",
145+
"default": "./dist/diagnostics.mjs"
134146
}
135147
},
136148
"scripts": {

src/_types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export interface BaseLoadOptions {
4747
consent?: boolean;
4848
/** Inject script as `<script type="text/partytown">` to offload to worker. */
4949
partytown?: boolean;
50+
/** zIndex for the launcher container; default 2147482647 (max - 1000). */
51+
zIndex?: number;
5052
}
5153

5254
export interface LoadOptions extends BaseLoadOptions {

src/capabilities.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Capability matrix — per-provider feature flags so consumers can
2+
// programmatically pick the right provider, or short-circuit code paths
3+
// when a capability isn't supported.
4+
5+
import type { ProviderName } from "./_types.ts";
6+
7+
export interface ProviderCapabilities {
8+
/** Identity verification supported (HMAC, JWT, callback). */
9+
hmac: boolean;
10+
jwt: boolean;
11+
callback: boolean;
12+
/** Per-message tracking. */
13+
trackEvents: boolean;
14+
/** Native unread-count callback. */
15+
unreadCount: boolean;
16+
/** prefill() programmatic compose. */
17+
prefill: boolean;
18+
/** setLocale at runtime (no remount). */
19+
setLocale: boolean;
20+
/** setTheme at runtime. */
21+
setTheme: boolean;
22+
/** Self-hosted base URL override. */
23+
selfHosted: boolean;
24+
/** Per-region selection (EU/US/AU). */
25+
regions: boolean;
26+
}
27+
28+
const NONE: ProviderCapabilities = {
29+
hmac: false,
30+
jwt: false,
31+
callback: false,
32+
trackEvents: false,
33+
unreadCount: false,
34+
prefill: false,
35+
setLocale: false,
36+
setTheme: false,
37+
selfHosted: false,
38+
regions: false,
39+
};
40+
41+
const TABLE: Record<ProviderName, ProviderCapabilities> = {
42+
intercom: { ...NONE, hmac: true, jwt: true, trackEvents: true, unreadCount: true, regions: true },
43+
crisp: { ...NONE, hmac: true, trackEvents: true, unreadCount: true, setLocale: true },
44+
tawk: { ...NONE, hmac: true, trackEvents: true, unreadCount: true },
45+
zendesk: {
46+
...NONE,
47+
jwt: true,
48+
callback: true,
49+
trackEvents: true,
50+
unreadCount: true,
51+
setLocale: true,
52+
},
53+
hubspot: { ...NONE, jwt: true, trackEvents: true, unreadCount: true, regions: true },
54+
chatwoot: {
55+
...NONE,
56+
hmac: true,
57+
trackEvents: true,
58+
unreadCount: true,
59+
setLocale: true,
60+
setTheme: true,
61+
selfHosted: true,
62+
},
63+
livechat: { ...NONE, trackEvents: true },
64+
drift: { ...NONE, jwt: true, trackEvents: true },
65+
freshchat: { ...NONE, jwt: true, trackEvents: true, regions: true },
66+
olark: { ...NONE, trackEvents: true },
67+
userlike: { ...NONE, trackEvents: true, setLocale: true },
68+
helpscout: { ...NONE, hmac: true, trackEvents: true, prefill: true },
69+
smartsupp: { ...NONE, trackEvents: true },
70+
liveagent: { ...NONE, selfHosted: true },
71+
gist: { ...NONE, hmac: true, trackEvents: true },
72+
jivochat: NONE,
73+
tidio: { ...NONE, trackEvents: true },
74+
sendbird: NONE,
75+
};
76+
77+
export function capabilities(provider: ProviderName): ProviderCapabilities {
78+
return TABLE[provider];
79+
}
80+
81+
export function supports(provider: ProviderName, feature: keyof ProviderCapabilities): boolean {
82+
return TABLE[provider][feature];
83+
}

src/diagnostics.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Dev-mode diagnostics — call once during local development to surface
2+
// common widget-script load failures (CORS, blocked URL, wrong key shape).
3+
// Wraps fetch() with HEAD where possible so we don't accidentally double-load
4+
// the snippet; falls back to script-tag probing when HEAD is blocked.
5+
6+
import { isBrowser } from "./_loader.ts";
7+
import type { ProviderName } from "./_types.ts";
8+
9+
interface DiagnosticResult {
10+
provider: ProviderName;
11+
url: string;
12+
ok: boolean;
13+
status?: number;
14+
error?: string;
15+
hint?: string;
16+
}
17+
18+
const PROVIDER_PROBE: Partial<Record<ProviderName, (config: Record<string, string>) => string>> = {
19+
intercom: (c) => `https://widget.intercom.io/widget/${c["appId"] ?? ""}`,
20+
crisp: () => "https://client.crisp.chat/l.js",
21+
tawk: (c) => `https://embed.tawk.to/${c["propertyId"] ?? ""}/${c["widgetId"] ?? "default"}`,
22+
zendesk: (c) => `https://static.zdassets.com/ekr/snippet.js?key=${c["key"] ?? ""}`,
23+
hubspot: (c) =>
24+
`https://${c["region"] === "eu1" ? "js-eu1" : "js"}.hs-scripts.com/${c["portalId"] ?? ""}.js`,
25+
chatwoot: (c) => `${c["baseUrl"] ?? "https://app.chatwoot.com"}/packs/js/sdk.js`,
26+
livechat: () => "https://cdn.livechatinc.com/tracking.js",
27+
drift: (c) => `https://js.driftt.com/include/latest/${c["embedId"] ?? ""}.js`,
28+
freshchat: (c) => `${c["host"] ?? "https://wchat.freshchat.com"}/js/widget.js`,
29+
helpscout: () => "https://beacon-v2.helpscout.net",
30+
smartsupp: () => "https://www.smartsuppchat.com/loader.js",
31+
jivochat: (c) => `https://code.jivosite.com/widget/${c["widgetId"] ?? ""}`,
32+
tidio: (c) => `https://code.tidio.co/${c["publicKey"] ?? ""}.js`,
33+
gist: () => "https://widget.getgist.com",
34+
olark: (c) => `https://www.olark.com/r3s/loader.js?l=${c["siteId"] ?? ""}`,
35+
liveagent: (c) =>
36+
`${
37+
c["selfHostedBaseUrl"] ?? `https://${c["accountSubdomain"] ?? ""}.ladesk.com`
38+
}/scripts/track.js`,
39+
userlike: (c) =>
40+
`https://userlike-cdn-widgets.s3-eu-west-1.amazonaws.com/${c["messengerId"] ?? ""}.js`,
41+
sendbird: () => "https://aichatbot.sendbird.com/index.js",
42+
};
43+
44+
interface FetchLike {
45+
(
46+
input: string,
47+
init?: { method?: string; mode?: string; redirect?: string },
48+
): Promise<{ ok: boolean; status: number }>;
49+
}
50+
51+
export async function diagnose(
52+
provider: ProviderName,
53+
config: Record<string, string>,
54+
): Promise<DiagnosticResult> {
55+
if (!isBrowser()) {
56+
return { provider, url: "", ok: false, error: "not-in-browser" };
57+
}
58+
const builder = PROVIDER_PROBE[provider];
59+
if (!builder) {
60+
return { provider, url: "", ok: false, error: "no-probe-for-provider" };
61+
}
62+
const url = builder(config);
63+
const fetchFn = (globalThis as unknown as { fetch?: FetchLike }).fetch;
64+
if (!fetchFn) {
65+
return { provider, url, ok: false, error: "fetch-unavailable" };
66+
}
67+
try {
68+
const res = await fetchFn(url, { method: "HEAD", mode: "no-cors" });
69+
if (!res.ok && res.status !== 0) {
70+
let hint: string | undefined;
71+
if (res.status === 400) hint = "Likely wrong key/account/portalId — provider returned 400.";
72+
if (res.status === 403) hint = "Forbidden — verify domain whitelist on provider dashboard.";
73+
if (res.status === 404)
74+
hint = "Snippet not found — typo in id or provider deactivated account.";
75+
return { provider, url, ok: false, status: res.status, hint };
76+
}
77+
return { provider, url, ok: true, status: res.status };
78+
} catch (err) {
79+
return {
80+
provider,
81+
url,
82+
ok: false,
83+
error: String(err),
84+
hint: "Network/CORS blocked the probe. The actual <script> tag may still load fine — this is a best-effort dev check.",
85+
};
86+
}
87+
}
88+
89+
export async function diagnoseAll(
90+
configs: Partial<Record<ProviderName, Record<string, string>>>,
91+
): Promise<DiagnosticResult[]> {
92+
const entries = Object.entries(configs) as Array<[ProviderName, Record<string, string>]>;
93+
return Promise.all(entries.map(([p, c]) => diagnose(p, c)));
94+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,7 @@ export {
3636
type CspDirectives,
3737
type CspOptions,
3838
} from "./csp.ts";
39+
export { capabilities, supports, type ProviderCapabilities } from "./capabilities.ts";
40+
export { diagnose, diagnoseAll } from "./diagnostics.ts";
3941

4042
export const version = "0.0.1";

src/providers/zendesk-classic.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Zendesk Web Widget (Classic) — predates Messenger. Different snippet, different
2+
// API surface (zE('webWidget', '...')). Kept separate from ahize/zendesk so
3+
// types don't pretend cross-compatibility.
4+
5+
import { waitForDefer } from "../_defer.ts";
6+
import { createIdentityStore } from "../_identity.ts";
7+
import { createLifecycle, hashConfig } from "../_lifecycle.ts";
8+
import { injectScript, isBrowser, removeScript } from "../_loader.ts";
9+
import { createQueue } from "../_queue.ts";
10+
import type {
11+
EventMetadata,
12+
Identity,
13+
IdentityListener,
14+
IdentityState,
15+
LoadOptions,
16+
} from "../_types.ts";
17+
18+
type ZendeskFn = (api: string, command: string, ...args: unknown[]) => void;
19+
20+
interface ZendeskWindow {
21+
zE?: ZendeskFn;
22+
zESettings?: Record<string, unknown>;
23+
}
24+
25+
function w(): ZendeskWindow {
26+
return globalThis as unknown as ZendeskWindow;
27+
}
28+
29+
const queue = createQueue<ZendeskFn>();
30+
const store = createIdentityStore();
31+
const lifecycle = createLifecycle();
32+
33+
export interface ZendeskClassicLoadOptions extends LoadOptions {
34+
key: string;
35+
}
36+
37+
export async function load(options: ZendeskClassicLoadOptions): Promise<void> {
38+
if (!isBrowser()) return;
39+
if (options.consent === false) return;
40+
const h = hashConfig({ key: options.key });
41+
if (lifecycle.state() === "ready" && lifecycle.configHash() === h) return;
42+
if (lifecycle.configHash() && lifecycle.configHash() !== h) await destroy();
43+
44+
lifecycle.transition("loading");
45+
lifecycle.setConfigHash(h);
46+
await waitForDefer(options.defer ?? "immediate");
47+
48+
try {
49+
await injectScript({
50+
id: "ze-snippet",
51+
src: `https://static.zdassets.com/ekr/snippet.js?key=${options.key}`,
52+
nonce: options.nonce,
53+
partytown: options.partytown,
54+
});
55+
} catch (error) {
56+
lifecycle.transition("idle");
57+
lifecycle.clearConfigHash();
58+
throw error;
59+
}
60+
61+
const fn = w().zE;
62+
if (typeof fn === "function") queue.ready(fn);
63+
lifecycle.transition("ready");
64+
}
65+
66+
export function ready(): Promise<void> {
67+
if (!isBrowser()) return Promise.resolve();
68+
return queue.enqueue(() => {});
69+
}
70+
71+
export function identify(identity: Identity): Promise<void> {
72+
if (!isBrowser()) return Promise.resolve();
73+
store.identify(identity);
74+
return queue.enqueue((zE) => {
75+
zE("webWidget", "prefill", {
76+
name: { value: identity.name, readOnly: false },
77+
email: { value: identity.email, readOnly: false },
78+
phone: { value: identity.phone, readOnly: false },
79+
});
80+
});
81+
}
82+
83+
export function track<T extends EventMetadata = EventMetadata>(
84+
event: string,
85+
metadata?: T,
86+
): Promise<void> {
87+
if (!isBrowser()) return Promise.resolve();
88+
return queue.enqueue((zE) => {
89+
zE("webWidget", "updatePath", { url: location.href, title: event });
90+
if (metadata) {
91+
zE("webWidget", "updateSettings", {
92+
webWidget: { contactForm: { tags: Object.keys(metadata) } },
93+
});
94+
}
95+
});
96+
}
97+
98+
export function pageView(_info?: { path?: string; locale?: string }): Promise<void> {
99+
if (!isBrowser()) return Promise.resolve();
100+
return queue.enqueue((zE) => zE("webWidget", "updatePath"));
101+
}
102+
103+
export function show(): Promise<void> {
104+
if (!isBrowser()) return Promise.resolve();
105+
return queue.enqueue((zE) => {
106+
zE("webWidget", "show");
107+
zE("webWidget", "open");
108+
});
109+
}
110+
111+
export function hide(): Promise<void> {
112+
if (!isBrowser()) return Promise.resolve();
113+
return queue.enqueue((zE) => zE("webWidget", "hide"));
114+
}
115+
116+
export function shutdown(): Promise<void> {
117+
if (!isBrowser()) return Promise.resolve();
118+
return queue
119+
.enqueue((zE) => zE("webWidget", "logout"))
120+
.then(() => {
121+
store.reset();
122+
lifecycle.transition("shutdown");
123+
});
124+
}
125+
126+
export async function destroy(): Promise<void> {
127+
if (!isBrowser()) return;
128+
await shutdown().catch(() => undefined);
129+
removeScript("ze-snippet");
130+
const g = w();
131+
Reflect.deleteProperty(g, "zE");
132+
Reflect.deleteProperty(g, "zESettings");
133+
queue.reset();
134+
store.reset();
135+
lifecycle.clearConfigHash();
136+
lifecycle.transition("idle");
137+
}
138+
139+
export function getIdentity(): IdentityState {
140+
return store.get();
141+
}
142+
143+
export function onIdentityChange(listener: IdentityListener): () => void {
144+
return store.onChange(listener);
145+
}
146+
147+
export function isReady(): boolean {
148+
return lifecycle.state() === "ready";
149+
}
150+
151+
export function state(): "idle" | "loading" | "ready" | "shutdown" {
152+
return lifecycle.state();
153+
}

0 commit comments

Comments
 (0)