diff --git a/docs/content/scripts/tawk-to.md b/docs/content/scripts/tawk-to.md new file mode 100644 index 00000000..5a5b61c8 --- /dev/null +++ b/docs/content/scripts/tawk-to.md @@ -0,0 +1,138 @@ +--- +title: Tawk.to +description: Load the Tawk.to live chat widget and drive it through a typed proxy, reactive state, and event listeners. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/tawk-to.ts + size: xs +--- + +[Tawk.to](https://www.tawk.to/) is a free live chat widget. + +[`useScriptTawkTo()`{lang="ts"}](/scripts/tawk-to) loads the widget, types the `window.Tawk_API` command surface, and bridges the `window` events the embed script dispatches into reactive state and typed listeners. + +::script-stats +:: + +::script-docs +:: + +The composable uses these defaults: + +- **Trigger: `onNuxtReady`.** The script loads after Nuxt hydration, using the module-wide default. +- **Bundle and proxy: off.** Tawk's runtime network behavior (whether the embed script derives its own API origin from its `src`, whether it opens connections a proxy would sit in front of for live-chat polling) hasn't been verified, so neither capability is declared yet. + +The widget needs both `propertyId` and `widgetId` to load. Find them under **Administration Settings → Channels → Chat Widget** in your Tawk.to dashboard. + +::code-group + +```ts [Proxy] +const { proxy } = useScriptTawkTo({ + propertyId: 'your-property-id', + widgetId: 'your-widget-id', +}) + +function openChat() { + proxy.maximize() +} +``` + +```ts [onLoaded] +const { onLoaded } = useScriptTawkTo({ + propertyId: 'your-property-id', + widgetId: 'your-widget-id', +}) + +onLoaded((Tawk_API) => { + Tawk_API.maximize() +}) +``` + +:: + +## Reactive state and events + +Tawk's embed script dispatches `window` `CustomEvent`s (`tawkLoad`, `tawkStatusChange`, `tawkChatMaximized`, …) alongside its documented `Tawk_API.onXxx = fn` callback-property API. `useScriptTawkTo()`{lang="ts"} bridges those events into five readonly refs and twenty typed listeners, so you don't have to wire `window.addEventListener` yourself: + +```vue + + + +``` + +`chatStatus` is Tawk's own online/away/offline operator status (`getStatus()`{lang="ts"}). It's distinct from `status`, the generic script-load state every registry entry exposes. + +Every `onXxx` listener returns a teardown function for use with `onScopeDispose`, mirroring the rest of the registry's event-listener helpers. + +The state refs are a single instance shared by every `useScriptTawkTo()`{lang="ts"} call on the page (there's only ever one Tawk widget), not one instance per call. + +## Getters + +`proxy` is fire-and-forget: calls queue until the script loads and replay once it does, but their return value is always discarded, even after loading. That's fine for actions like `proxy.maximize()`{lang="ts"}, which don't return anything meaningful anyway, but it can't carry a real synchronous getter. `getWindowType`, `getStatus`, `isChatMaximized`, `isChatMinimized`, `isChatHidden`, `isChatOngoing`, `isVisitorEngaged`, and `widgetPosition` are exposed directly on `useScriptTawkTo()`{lang="ts"}'s return value instead, calling straight through to `window.Tawk_API`: + +```ts +const { getStatus, isChatHidden } = useScriptTawkTo({ + propertyId: 'your-property-id', + widgetId: 'your-widget-id', +}) + +getStatus() // 'online' | 'away' | 'offline' | undefined +isChatHidden() // boolean, false before the widget has loaded +``` + +## Identifying visitors + +`proxy.visitor = {...}` doesn't work for the same reason: unhead's script proxy has no `set` trap, so a property assignment through it never reaches the real `Tawk_API`. Use `setVisitor()`{lang="ts"} instead: + +`setVisitor()`{lang="ts"} is pre-load only. Tawk honors `Tawk_API.visitor` before the embed script loads and ignores it afterwards. If the widget is already loaded (`onLoaded` is set), it warns and does nothing. For post-load identity changes, use `window.Tawk_API.setAttributes({ name, email, hash })`{lang="ts"}: + +```ts +const { proxy, setVisitor } = useScriptTawkTo({ + propertyId: 'your-property-id', + widgetId: 'your-widget-id', +}) + +setVisitor({ + name: 'Jane Doe', + email: 'jane@example.com', + // HMAC-SHA256 signature for Secure Mode, generated server-side + hash: visitorHash, +}) +proxy.setAttributes({ plan: 'pro' }) +proxy.addTags(['vip']) +``` + +## Switching properties at runtime + +```ts +const { proxy } = useScriptTawkTo({ + propertyId: 'your-property-id', + widgetId: 'your-widget-id', +}) + +proxy.switchWidget({ propertyId: 'other-property-id', widgetId: 'other-widget-id' }) +``` + +::script-types +:: + +## Partytown + +Do not run Tawk.to under Partytown. The widget renders DOM overlays (the chat bubble, prechat and full chat panels) directly, and the `window` `CustomEvent`s the reactive state and listeners depend on aren't configured for worker forwarding. diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 0e2e3dec..17866a64 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -55,6 +55,7 @@ export const LOGOS = { light: ``, dark: ``, }, + tawkTo: ``, crisp: { light: ``, dark: ``, diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index e9a7be70..46473c1d 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -1186,6 +1186,48 @@ "code": "interface ScriptStripePricingTableSlots {\n default?: () => any\n loading?: () => any\n awaitingLoad?: () => any\n error?: () => any\n}" } ], + "tawk-to": [ + { + "name": "TawkToOptions", + "kind": "const", + "code": "export const TawkToOptions = object({\n /**\n * Your Tawk.to property ID.\n * @see https://nuxt-tawk-to.atlaxt.me/getting-started/installation#register-module\n */\n propertyId: pipe(string(), minLength(1)),\n /**\n * Your Tawk.to widget ID.\n * @see https://nuxt-tawk-to.atlaxt.me/getting-started/installation#register-module\n */\n widgetId: pipe(string(), minLength(1)),\n})" + }, + { + "name": "TawkToStatus", + "kind": "type", + "code": "export type TawkToStatus = 'online' | 'away' | 'offline'" + }, + { + "name": "TawkToWindowType", + "kind": "type", + "code": "export type TawkToWindowType = 'inline' | 'embed'" + }, + { + "name": "TawkToWidgetPosition", + "kind": "type", + "code": "export type TawkToWidgetPosition = 'br' | 'bl' | 'cr' | 'cl' | 'tr' | 'tl'" + }, + { + "name": "TawkToVisitor", + "kind": "interface", + "code": "export interface TawkToVisitor {\n name?: string\n email?: string\n /** HMAC-SHA256 signature for Tawk's Secure Mode, generated server-side by the consumer. */\n hash?: string\n}" + }, + { + "name": "TawkToApi", + "kind": "interface", + "code": "export interface TawkToApi {\n start: () => void\n shutdown: () => void\n maximize: () => void\n minimize: () => void\n toggle: () => void\n popup: () => void\n showWidget: () => void\n hideWidget: () => void\n toggleVisibility: () => void\n endChat: () => void\n\n getWindowType: () => TawkToWindowType\n getStatus: () => TawkToStatus\n isChatMaximized: () => boolean\n isChatMinimized: () => boolean\n isChatHidden: () => boolean\n isChatOngoing: () => boolean\n isVisitorEngaged: () => boolean\n /** Set by Tawk once the widget has finished loading. */\n onLoaded?: 1\n /** Set by Tawk before the widget begins loading. */\n onBeforeLoaded?: boolean\n widgetPosition: () => TawkToWidgetPosition\n\n visitor?: TawkToVisitor\n setAttributes: (attributes: Record, callback?: (error: Error | null) => void) => void\n addEvent: (event: string, metadata?: Record, callback?: (error: Error | null) => void) => void\n addTags: (tags: string[], callback?: (error: Error | null) => void) => void\n removeTags: (tags: string[], callback?: (error: Error | null) => void) => void\n switchWidget: (data: { propertyId: string, widgetId: string }, callback?: () => void) => void\n}" + }, + { + "name": "TawkToProxyApi", + "kind": "type", + "code": "export type TawkToProxyApi = Omit" + }, + { + "name": "TawkToEvents", + "kind": "interface", + "code": "export interface TawkToEvents {\n isHidden: Readonly>\n isMinimized: Readonly>\n isMaximized: Readonly>\n /** The operator status Tawk reports (`getStatus()`) — not to be confused with `status`, the script's own load state. */\n chatStatus: Readonly>\n unreadCount: Readonly>\n\n // Getters, called directly against `window.Tawk_API` (see `TawkToProxyApi`'s\n // doc comment for why `proxy` can't carry these). `undefined` before the\n // widget has loaded; the four `isXxx` booleans default to `false` instead\n // since a definite \"no\" is a safe, honest answer before load.\n getWindowType: () => TawkToWindowType | undefined\n getStatus: () => TawkToStatus | undefined\n isChatMaximized: () => boolean\n isChatMinimized: () => boolean\n isChatHidden: () => boolean\n isChatOngoing: () => boolean\n isVisitorEngaged: () => boolean\n widgetPosition: () => TawkToWidgetPosition | undefined\n\n /** Sets `Tawk_API.visitor` directly - assigning through `proxy.visitor` is a no-op (no `set` trap). */\n setVisitor: (data: TawkToVisitor) => void\n\n onLoad: (cb: () => void) => () => void\n onBeforeLoad: (cb: () => void) => () => void\n onStatusChange: (cb: (status: TawkToStatus) => void) => () => void\n onChatMaximized: (cb: () => void) => () => void\n onChatMinimized: (cb: () => void) => () => void\n onChatHidden: (cb: () => void) => () => void\n onChatStarted: (cb: () => void) => () => void\n onChatEnded: (cb: () => void) => () => void\n onPrechatSubmit: (cb: (data: Record) => void) => () => void\n onOfflineSubmit: (cb: (data: Record) => void) => () => void\n onChatMessageVisitor: (cb: (message: string) => void) => () => void\n onChatMessageAgent: (cb: (message: string) => void) => () => void\n onChatMessageSystem: (cb: (message: string) => void) => () => void\n onAgentJoinChat: (cb: (data: Record) => void) => () => void\n onAgentLeaveChat: (cb: (data: Record) => void) => () => void\n onChatSatisfaction: (cb: (satisfaction: number) => void) => () => void\n onVisitorNameChanged: (cb: (visitorName: string) => void) => () => void\n onFileUpload: (cb: (link: string) => void) => () => void\n onTagsUpdated: (cb: (data: Record) => void) => () => void\n onUnreadCountChanged: (cb: (count: number) => void) => () => void\n}" + } + ], "tiktok-pixel": [ { "name": "StandardEvents", @@ -2743,6 +2785,20 @@ "defaultValue": "'basil'" } ], + "TawkToOptions": [ + { + "name": "propertyId", + "type": "string", + "required": true, + "description": "Your Tawk.to property ID." + }, + { + "name": "widgetId", + "type": "string", + "required": true, + "description": "Your Tawk.to widget ID." + } + ], "TikTokPixelOptions": [ { "name": "id", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index b1c84d3e..6cff3cca 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -47,6 +47,7 @@ import { SnapTrPixelOptions, SpeedCurveOptions, StripeOptions, + TawkToOptions, TikTokPixelOptions, UmamiAnalyticsOptions, UsercentricsOptions, @@ -170,6 +171,7 @@ export const registryMeta: RegistryScriptMeta[] = [ m('intercom', 'Intercom', 'support', 'useScriptIntercom', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), m('crisp', 'Crisp', 'support', 'useScriptCrisp', { bundle: true }, null), m('deskcrew', 'DeskCrew', 'support', 'useScriptDeskCrew', {}, null), + m('tawkTo', 'Tawk.to', 'support', 'useScriptTawkTo', {}, null), // cdn m('npm', 'NPM', 'cdn', 'useScriptNpm', { bundle: true }, null), // utility @@ -764,6 +766,13 @@ export async function registry(resolve?: (path: string) => Promise): Pro composableName: 'useScriptDeskCrew', envDefaults: { widgetKey: '', board: '' }, }), + // Bundle/proxy: unverified for Tawk, left off rather than guessed at. + def('tawkTo', { + schema: TawkToOptions, + label: 'Tawk.to', + category: 'support', + envDefaults: { propertyId: '', widgetId: '' }, + }), // cdn def('npm', { schema: NpmOptions, diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 815800a1..3b81cb4e 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -1174,6 +1174,19 @@ export const StripeOptions = object({ version: optional(union([literal('v3'), literal('acacia'), literal('basil'), literal('clover'), literal('dahlia'), string()])), }) +export const TawkToOptions = object({ + /** + * Your Tawk.to property ID. + * @see https://nuxt-tawk-to.atlaxt.me/getting-started/installation#register-module + */ + propertyId: pipe(string(), minLength(1)), + /** + * Your Tawk.to widget ID. + * @see https://nuxt-tawk-to.atlaxt.me/getting-started/installation#register-module + */ + widgetId: pipe(string(), minLength(1)), +}) + export const TikTokPixelOptions = object({ /** * Your TikTok Pixel ID. diff --git a/packages/script/src/runtime/registry/tawk-to.ts b/packages/script/src/runtime/registry/tawk-to.ts new file mode 100644 index 00000000..c4aeba45 --- /dev/null +++ b/packages/script/src/runtime/registry/tawk-to.ts @@ -0,0 +1,278 @@ +import type { Ref } from 'vue' +import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' +import { joinURL } from 'ufo' +import { ref } from 'vue' +import { useRegistryScript } from '../utils' +import { TawkToOptions } from './schemas' + +export { TawkToOptions } + +export type TawkToInput = RegistryScriptInput + +export type TawkToStatus = 'online' | 'away' | 'offline' +export type TawkToWindowType = 'inline' | 'embed' +export type TawkToWidgetPosition = 'br' | 'bl' | 'cr' | 'cl' | 'tr' | 'tl' + +export interface TawkToVisitor { + name?: string + email?: string + /** HMAC-SHA256 signature for Tawk's Secure Mode, generated server-side by the consumer. */ + hash?: string +} + +/** + * The `window.Tawk_API` surface, as exposed through `proxy`. `proxy` is + * fire-and-forget: unhead's script proxy queues/replays calls but always + * discards their return value, even once the script is fully loaded (and it + * has no `set` trap, so plain property assignment through it goes nowhere). + * That means only void-returning actions belong here - real getters and the + * `visitor` setter live on `useScriptTawkTo()`'s own return value instead + * (see `TawkToEvents`), backed directly by `window.Tawk_API`. + * @see https://developer.tawk.to/jsapi/ + */ +export interface TawkToApi { + start: () => void + shutdown: () => void + maximize: () => void + minimize: () => void + toggle: () => void + popup: () => void + showWidget: () => void + hideWidget: () => void + toggleVisibility: () => void + endChat: () => void + + getWindowType: () => TawkToWindowType + getStatus: () => TawkToStatus + isChatMaximized: () => boolean + isChatMinimized: () => boolean + isChatHidden: () => boolean + isChatOngoing: () => boolean + isVisitorEngaged: () => boolean + /** Set by Tawk once the widget has finished loading. The embed writes a boolean (`!0`), not a number. */ + onLoaded?: boolean + /** Set by Tawk before the widget begins loading. */ + onBeforeLoaded?: boolean + widgetPosition: () => TawkToWidgetPosition + + visitor?: TawkToVisitor + setAttributes: (attributes: Record, callback?: (error: Error | null) => void) => void + addEvent: (event: string, metadata?: Record, callback?: (error: Error | null) => void) => void + addTags: (tags: string[], callback?: (error: Error | null) => void) => void + removeTags: (tags: string[], callback?: (error: Error | null) => void) => void + switchWidget: (data: { propertyId: string, widgetId: string }, callback?: () => void) => void +} + +declare global { + interface Window { + Tawk_API?: TawkToApi + Tawk_LoadStart?: Date + } +} + +/** + * The subset of `TawkToApi` that `proxy` actually exposes: the getters, the + * `visitor` setter and the `onLoaded`/`onBeforeLoaded` flags are omitted since + * none of them work through the fire-and-forget proxy (see `TawkToApi`'s doc + * comment). They're still real members of the underlying `window.Tawk_API` + * object, just not reachable via `proxy`. + */ +export type TawkToProxyApi = Omit + +/** + * Reactive state and typed event listeners bridged from the `window` + * `CustomEvent`s Tawk's embed script dispatches (`tawkLoad`, `tawkStatusChange`, etc). + * These aren't part of the documented `Tawk_API.onXxx = fn` callback-property API - + * they're a separate, verified-in-the-browser signal the embed script also fires. + */ +export interface TawkToEvents { + isHidden: Readonly> + isMinimized: Readonly> + isMaximized: Readonly> + /** The operator status Tawk reports (`getStatus()`) — not to be confused with `status`, the script's own load state. */ + chatStatus: Readonly> + unreadCount: Readonly> + + // Getters, called directly against `window.Tawk_API` (see `TawkToProxyApi`'s + // doc comment for why `proxy` can't carry these). `undefined` before the + // widget has loaded; the four `isXxx` booleans default to `false` instead + // since a definite "no" is a safe, honest answer before load. + getWindowType: () => TawkToWindowType | undefined + getStatus: () => TawkToStatus | undefined + isChatMaximized: () => boolean + isChatMinimized: () => boolean + isChatHidden: () => boolean + isChatOngoing: () => boolean + isVisitorEngaged: () => boolean + widgetPosition: () => TawkToWidgetPosition | undefined + + /** + * Sets `Tawk_API.visitor` directly - assigning through `proxy.visitor` is a no-op + * (no `set` trap). Pre-load only: Tawk honors `Tawk_API.visitor` before the embed + * script loads, so a call after `onLoaded` warns and does nothing - use + * `window.Tawk_API.setAttributes()` for post-load identity changes. + */ + setVisitor: (data: TawkToVisitor) => void + + onLoad: (cb: () => void) => () => void + onBeforeLoad: (cb: () => void) => () => void + onStatusChange: (cb: (status: TawkToStatus) => void) => () => void + onChatMaximized: (cb: () => void) => () => void + onChatMinimized: (cb: () => void) => () => void + onChatHidden: (cb: () => void) => () => void + onChatStarted: (cb: () => void) => () => void + onChatEnded: (cb: () => void) => () => void + onPrechatSubmit: (cb: (data: Record) => void) => () => void + onOfflineSubmit: (cb: (data: Record) => void) => () => void + onChatMessageVisitor: (cb: (message: string) => void) => () => void + onChatMessageAgent: (cb: (message: string) => void) => () => void + onChatMessageSystem: (cb: (message: string) => void) => () => void + onAgentJoinChat: (cb: (data: Record) => void) => () => void + onAgentLeaveChat: (cb: (data: Record) => void) => () => void + onChatSatisfaction: (cb: (satisfaction: number) => void) => () => void + onVisitorNameChanged: (cb: (visitorName: string) => void) => () => void + onFileUpload: (cb: (link: string) => void) => () => void + onTagsUpdated: (cb: (data: Record) => void) => () => void + onUnreadCountChanged: (cb: (count: number) => void) => () => void +} + +// One Tawk widget exists per page, so its derived state is a module-level +// singleton shared by every useScriptTawkTo() call - not one ref tree per +// call, and not Nuxt's useState() (nothing here is ever known during SSR). +const isHidden = ref(false) +const isMinimized = ref(false) +const isMaximized = ref(false) +const chatStatus = ref('offline') +const unreadCount = ref(0) + +function listen(event: string, cb: (detail: D) => void): () => void { + if (import.meta.server) + return () => {} + const handler = (e: Event) => cb((e as CustomEvent).detail) + window.addEventListener(event, handler) + return () => window.removeEventListener(event, handler) +} + +// Bridges the window CustomEvents into the refs above. Guarded so it only +// wires up once no matter how many times useScriptTawkTo() is called. +let stateBridged = false +function ensureStateBridge() { + if (stateBridged) + return + stateBridged = true + listen('tawkLoad', () => { + isHidden.value = !!window.Tawk_API?.isChatHidden() + isMinimized.value = !!window.Tawk_API?.isChatMinimized() + isMaximized.value = !!window.Tawk_API?.isChatMaximized() + chatStatus.value = window.Tawk_API?.getStatus() ?? 'offline' + }) + listen('tawkStatusChange', (detail) => { + chatStatus.value = detail + }) + listen('tawkChatHidden', () => { + isHidden.value = true + }) + listen('tawkChatMinimized', () => { + isMinimized.value = true + isMaximized.value = false + }) + listen('tawkChatMaximized', () => { + isMaximized.value = true + isMinimized.value = false + }) + listen('tawkUnreadCountChanged', (detail) => { + unreadCount.value = detail + }) +} + +export function useScriptTawkTo(_options?: TawkToInput): UseScriptContext & TawkToEvents { + const instance = useRegistryScript('tawkTo', options => ({ + scriptInput: { + src: joinURL('https://embed.tawk.to', options.propertyId, options.widgetId), + async: true, + crossorigin: 'anonymous', + }, + schema: import.meta.dev ? TawkToOptions : undefined, + scriptOptions: { + resolve({ waitFor }) { + if (window.Tawk_API?.onLoaded) + return window.Tawk_API as TawkToProxyApi as T + + return waitFor((resolve, reject) => { + const stop = listen('tawkLoad', () => { + stop() + if (window.Tawk_API) + resolve(window.Tawk_API as TawkToProxyApi as T) + else + reject(new Error('[nuxt-scripts] Tawk.to reported ready without exposing window.Tawk_API')) + }) + return stop + }) + }, + }, + clientInit: import.meta.server + ? undefined + : () => { + window.Tawk_API = window.Tawk_API || {} as TawkToApi + window.Tawk_LoadStart = new Date() + }, + }), _options) as UseScriptContext & TawkToEvents + + if (!import.meta.server) + ensureStateBridge() + + // The refs are module-level singletons (see above); consumers only get the + // Readonly> view so mutation stays confined to the state bridge. + instance.isHidden = isHidden + instance.isMinimized = isMinimized + instance.isMaximized = isMaximized + instance.chatStatus = chatStatus + instance.unreadCount = unreadCount + instance.onLoad = cb => listen('tawkLoad', cb) + instance.onBeforeLoad = cb => listen('tawkBeforeLoad', cb) + instance.onStatusChange = cb => listen('tawkStatusChange', cb) + instance.onChatMaximized = cb => listen('tawkChatMaximized', cb) + instance.onChatMinimized = cb => listen('tawkChatMinimized', cb) + instance.onChatHidden = cb => listen('tawkChatHidden', cb) + instance.onChatStarted = cb => listen('tawkChatStarted', cb) + instance.onChatEnded = cb => listen('tawkChatEnded', cb) + instance.onPrechatSubmit = cb => listen('tawkPrechatSubmit', cb) + instance.onOfflineSubmit = cb => listen('tawkOfflineSubmit', cb) + instance.onChatMessageVisitor = cb => listen('tawkChatMessageVisitor', cb) + instance.onChatMessageAgent = cb => listen('tawkChatMessageAgent', cb) + instance.onChatMessageSystem = cb => listen('tawkChatMessageSystem', cb) + instance.onAgentJoinChat = cb => listen('tawkAgentJoinChat', cb) + instance.onAgentLeaveChat = cb => listen('tawkAgentLeaveChat', cb) + instance.onChatSatisfaction = cb => listen('tawkChatSatisfaction', cb) + instance.onVisitorNameChanged = cb => listen('tawkVisitorNameChanged', cb) + instance.onFileUpload = cb => listen('tawkFileUpload', cb) + instance.onTagsUpdated = cb => listen('tawkTagsUpdated', cb) + instance.onUnreadCountChanged = cb => listen('tawkUnreadCountChanged', cb) + + // Called directly against window.Tawk_API rather than through `proxy`, + // which discards every return value (see TawkToProxyApi's doc comment). + instance.getWindowType = () => import.meta.server ? undefined : window.Tawk_API?.getWindowType() + instance.getStatus = () => import.meta.server ? undefined : window.Tawk_API?.getStatus() + instance.isChatMaximized = () => !import.meta.server && !!window.Tawk_API?.isChatMaximized() + instance.isChatMinimized = () => !import.meta.server && !!window.Tawk_API?.isChatMinimized() + instance.isChatHidden = () => !import.meta.server && !!window.Tawk_API?.isChatHidden() + instance.isChatOngoing = () => !import.meta.server && !!window.Tawk_API?.isChatOngoing() + instance.isVisitorEngaged = () => !import.meta.server && !!window.Tawk_API?.isVisitorEngaged() + instance.widgetPosition = () => import.meta.server ? undefined : window.Tawk_API?.widgetPosition() + instance.setVisitor = (data) => { + if (import.meta.server) + return + // Before clientInit creates the stub, create or reuse it so the visitor is + // not silently dropped - Tawk honors `Tawk_API.visitor` set pre-load. + window.Tawk_API = window.Tawk_API || {} as TawkToApi + // Tawk only honors `Tawk_API.visitor` before the embed script loads; after + // `onLoaded`, identity changes must go through `setAttributes()` instead. + if (window.Tawk_API.onLoaded) { + console.warn('[nuxt-scripts] Tawk.to: setVisitor() only works before the widget loads. Tawk ignores it once onLoaded is set - use window.Tawk_API.setAttributes({ name, email, hash }) instead.') + return + } + window.Tawk_API.visitor = data + } + + return instance +} diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index edcf317c..154efca4 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -42,6 +42,7 @@ import type { SegmentInput } from './registry/segment' import type { SnapTrPixelInput } from './registry/snapchat-pixel' import type { SpeedCurveInput } from './registry/speedcurve' import type { StripeInput } from './registry/stripe' +import type { TawkToInput } from './registry/tawk-to' import type { TikTokPixelInput } from './registry/tiktok-pixel' import type { UmamiAnalyticsInput } from './registry/umami-analytics' import type { UsercentricsInput } from './registry/usercentrics' @@ -284,6 +285,7 @@ export interface ScriptRegistry { segment?: SegmentInput speedcurve?: SpeedCurveInput stripe?: StripeInput + tawkTo?: TawkToInput tiktokPixel?: TikTokPixelInput xEmbed?: XEmbedInput xPixel?: XPixelInput @@ -308,7 +310,7 @@ export type BuiltInRegistryScriptKey | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'leaflet' | 'maplibre' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'matomoAnalytics' - | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'stripe' | 'tiktokPixel' + | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'stripe' | 'tawkTo' | 'tiktokPixel' | 'xEmbed' | 'xPixel' | 'snapchatPixel' | 'speedcurve' | 'youtubePlayer' | 'vercelAnalytics' | 'vimeoPlayer' | 'umamiAnalytics' | 'usercentrics' | 'gravatar' | 'npm' diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index 59c490a6..ac0db987 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -147,6 +147,11 @@ export const scriptMeta = { trackedData: ['user-identity', 'events', 'errors'], testId: 'pub_deskcrewdemo', }, + tawkTo: { + urls: ['https://embed.tawk.to/68496650ddf9cd19094b4530/1itfbfagd'], + trackedData: ['user-identity', 'events'], + testId: '68496650ddf9cd19094b4530', + }, crisp: { urls: ['https://client.crisp.chat/l.js'], diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 93dc2b64..06447cf3 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -40,6 +40,7 @@ export default defineNuxtConfig({ intercom: { app_id: 'akg5rmxb' }, crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, deskcrew: { widgetKey: 'pub_deskcrewdemo', board: 'demo' }, + tawkTo: { propertyId: '68496650ddf9cd19094b4530', widgetId: '1itfbfagd' }, }, }, }, @@ -109,6 +110,7 @@ export default defineNuxtConfig({ intercom: { app_id: 'akg5rmxb', trigger: 'manual' }, crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417', trigger: 'manual' }, deskcrew: { widgetKey: 'pub_deskcrewdemo', board: 'demo', trigger: 'manual' }, + tawkTo: { propertyId: '68496650ddf9cd19094b4530', widgetId: '1itfbfagd', trigger: 'manual' }, // Media youtubePlayer: { trigger: 'manual' }, diff --git a/playground/pages/index.vue b/playground/pages/index.vue index e69ff39b..dcc95d1a 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -40,6 +40,7 @@ function getPlaygroundPath(script: any): string | null { 'intercom': '/third-parties/intercom/facade', 'crisp': '/third-parties/crisp/facade', 'deskcrew': '/third-parties/deskcrew/facade', + 'tawk.to': '/third-parties/tawk-to/use-script', 'stripe': '/third-parties/stripe/nuxt-scripts', 'paypal': '/third-parties/paypal/nuxt-scripts', 'lemon-squeezy': '/third-parties/lemon-squeezy/component', diff --git a/playground/pages/third-parties/tawk-to/use-script.vue b/playground/pages/third-parties/tawk-to/use-script.vue new file mode 100644 index 00000000..1a5a20a9 --- /dev/null +++ b/playground/pages/third-parties/tawk-to/use-script.vue @@ -0,0 +1,320 @@ + + + diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index cdde41b5..545a70e4 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -2,6 +2,7 @@ import type { ModuleOptions } from '../../packages/script/src/module' import type { CrispApi } from '../../packages/script/src/runtime/registry/crisp' import type { DeskCrewApi, DeskCrewEmbedOptions } from '../../packages/script/src/runtime/registry/deskcrew' import type { DefaultEventName } from '../../packages/script/src/runtime/registry/google-analytics' +import type { TawkToProxyApi } from '../../packages/script/src/runtime/registry/tawk-to' import type { TikTokPixelApi, useScriptTikTokPixel } from '../../packages/script/src/runtime/registry/tiktok-pixel' import type { NuxtConfigScriptRegistry, NuxtConfigScriptRegistryEntry, NuxtUseScriptOptions, RegistryScriptInput, ScriptRegistry, UseFunctionType, UseScriptContext } from '../../packages/script/src/runtime/types' import { describe, expectTypeOf, it } from 'vitest' @@ -47,6 +48,7 @@ describe('module options registry', () => { expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() @@ -214,3 +216,13 @@ describe('tiktok pixel ttq methods', () => { expectTypeOf().toBeCallableWith('StartTrial') }) }) + +describe('tawk-to proxy api', () => { + it('omits the load flags the fire-and-forget proxy cannot carry', () => { + // `onLoaded`/`onBeforeLoaded` are plain data properties Tawk writes itself; + // the proxy has no set trap and discards values, so a typed read of them + // through `proxy` would always be wrong. + type Flags = Extract + expectTypeOf().toBeNever() + }) +}) diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index e819b274..6f895128 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -401,6 +401,11 @@ describe('proxy configs', () => { expect(config).toBeUndefined() }) + it('does not return proxy config for tawkTo (runtime network behavior unverified)', async () => { + const config = (await getProxyConfigs()).tawkTo + expect(config).toBeUndefined() + }) + it('returns proxy config for calendly', async () => { const config = (await getProxyConfigs()).calendly expect(config).toBeDefined() @@ -455,6 +460,7 @@ describe('proxy configs', () => { expect(configs).toHaveProperty('intercom') expect(configs).not.toHaveProperty('crisp') expect(configs).not.toHaveProperty('deskcrew') + expect(configs).not.toHaveProperty('tawkTo') expect(configs).toHaveProperty('vercelAnalytics') expect(configs).toHaveProperty('gravatar') expect(configs).toHaveProperty('calendly') diff --git a/test/unit/registry-readiness.test.ts b/test/unit/registry-readiness.test.ts index a14c210a..957880be 100644 --- a/test/unit/registry-readiness.test.ts +++ b/test/unit/registry-readiness.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' import { useScriptCrisp } from '../../packages/script/src/runtime/registry/crisp' import { useScriptGoogleMaps } from '../../packages/script/src/runtime/registry/google-maps' +import { useScriptTawkTo } from '../../packages/script/src/runtime/registry/tawk-to' import { useScriptUsercentrics } from '../../packages/script/src/runtime/registry/usercentrics' const mocks = vi.hoisted(() => ({ @@ -45,12 +46,14 @@ describe('registry script readiness resolvers', () => { delete (window as any).$crisp delete window.CRISP_READY_TRIGGER delete window.__ucCmp + delete window.Tawk_API mocks.useRegistryScript.mockImplementation((key, factory) => { const options = { crisp: { id: 'website-id' }, googleMaps: { apiKey: 'maps-key' }, usercentrics: { rulesetId: 'ruleset-id' }, - }[key as 'crisp' | 'googleMaps' | 'usercentrics'] + tawkTo: { propertyId: 'test-property', widgetId: 'test-widget' }, + }[key as 'crisp' | 'googleMaps' | 'usercentrics' | 'tawkTo'] const definition = factory(options || {}) mocks.definitions.set(key, definition) return { @@ -108,4 +111,55 @@ describe('registry script readiness resolvers', () => { await expect(apiPromise).resolves.toEqual({ ucCmp: api }) expect(removeEventListener).toHaveBeenCalledWith('UC_CMP_API_READY', expect.any(Function)) }) + + it('resolves Tawk.to from the tawkLoad window event', async () => { + const api = { + onLoaded: undefined, + isChatHidden: vi.fn(() => false), + isChatMinimized: vi.fn(() => false), + isChatMaximized: vi.fn(() => false), + getStatus: vi.fn(() => 'online'), + } as any + window.Tawk_API = api + useScriptTawkTo({ propertyId: 'test-property', widgetId: 'test-widget' }) + const resolver = createResolverWait() + const apiPromise = mocks.definitions.get('tawkTo').scriptOptions.resolve(resolver) + + window.dispatchEvent(new CustomEvent('tawkLoad')) + + await expect(apiPromise).resolves.toBe(api) + }) + + // The shipped embed script writes `window.Tawk_API.onLoaded = !0` (boolean + // true), not the number 1 - the fast path must honor the real value. + it('resolves Tawk.to immediately when already loaded (onLoaded === true)', () => { + const api = { onLoaded: true } as any + window.Tawk_API = api + useScriptTawkTo({ propertyId: 'test-property', widgetId: 'test-widget' }) + const result = mocks.definitions.get('tawkTo').scriptOptions.resolve(createResolverWait()) + + expect(result).toBe(api) + }) + + it('warns when setVisitor is called after the widget has loaded (onLoaded === true)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const api = { onLoaded: true } as any + window.Tawk_API = api + const instance = useScriptTawkTo({ propertyId: 'test-property', widgetId: 'test-widget' }) + + instance.setVisitor({ name: 'Jane Doe' }) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('setVisitor')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('before the widget loads')) + warn.mockRestore() + }) + + it('creates the Tawk_API stub and assigns visitor when setVisitor is called before clientInit', () => { + const instance = useScriptTawkTo({ propertyId: 'test-property', widgetId: 'test-widget' }) + + instance.setVisitor({ name: 'Jane' }) + + expect(window.Tawk_API).toBeDefined() + expect(window.Tawk_API!.visitor).toEqual({ name: 'Jane' }) + }) }) diff --git a/test/unit/tawk-to-types.test.ts b/test/unit/tawk-to-types.test.ts new file mode 100644 index 00000000..fa068aa0 --- /dev/null +++ b/test/unit/tawk-to-types.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import registryTypes from '../../packages/script/src/registry-types.json' + +describe('tawk-to generated types', () => { + it('tawkToWindowType uses the documented inline/embed literals', () => { + const declarations = (registryTypes as any).types['tawk-to'] as Array<{ name: string, code: string }> + const declaration = declarations.find(d => d.name === 'TawkToWindowType') + expect(declaration).toBeDefined() + expect(declaration!.code).toMatch(/^export type TawkToWindowType = 'inline' \| 'embed'$/) + }) +})