diff --git a/docs/content/scripts/deskcrew.md b/docs/content/scripts/deskcrew.md new file mode 100644 index 00000000..19500af6 --- /dev/null +++ b/docs/content/scripts/deskcrew.md @@ -0,0 +1,110 @@ +--- +title: DeskCrew +description: Add a lazy-loaded DeskCrew support widget to your Nuxt app. +links: + - label: useScriptDeskCrew + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/deskcrew.ts + size: xs + - label: "" + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/components/ScriptDeskCrew.vue + size: xs +--- + +[DeskCrew](https://deskcrew.io/) is a support widget combining live chat, AI answers from your +knowledge base, a help centre and a changelog. + +Use [`useScriptDeskCrew()`{lang="ts"}](#usescriptdeskcrew){lang="ts"} for direct SDK calls, or +[``{lang="html"}](#scriptdeskcrew){lang="html"} for a custom chat launcher. + +::script-stats +:: + +::script-docs +:: + +## [``{lang="html"}](/scripts/deskcrew){lang="html"} + +The headless facade holds back the DeskCrew widget until its +[element trigger](/docs/guides/script-triggers#element-event-triggers) fires. It listens for +`click` by default, so a visitor who never opens chat downloads none of the widget. + +### Component API + +See the [Facade Component API](/docs/guides/facade-components#facade-components-api) for full +props, events, and slots. + +#### With environment variables + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + deskcrew: { trigger: 'onNuxtReady' }, + } + }, + runtimeConfig: { + public: { + scripts: { + deskcrew: { + widgetKey: '', // NUXT_PUBLIC_SCRIPTS_DESKCREW_WIDGET_KEY + board: '', // NUXT_PUBLIC_SCRIPTS_DESKCREW_BOARD + }, + }, + }, + }, +}) +``` + +```text [.env] +NUXT_PUBLIC_SCRIPTS_DESKCREW_WIDGET_KEY= +NUXT_PUBLIC_SCRIPTS_DESKCREW_BOARD= +``` + +### Events + +The component emits `ready` once the widget has mounted its launcher, and `error` if the script +fails to load. + +### Slots + +`awaitingLoad`, `loading`, `error` and the default slot behave as documented for facade +components. + +## [`useScriptDeskCrew()`{lang="ts"}](/scripts/deskcrew){lang="ts"} + +```ts +export function useScriptDeskCrew(_options?: DeskCrewInput) {} +``` + +::script-types +:: + +### Identifying a visitor + +Identity is a signed token minted by your own backend, so it is a runtime call rather than a +`nuxt.config` option. Everything in `nuxt.config` is a deploy-time constant, and baking one +visitor's token into a build would hand that identity to every other visitor. + +```vue + +``` + +### Other surfaces + +`embed()`{lang="ts"}, `changelog()`{lang="ts"} and `surveys()`{lang="ts"} each mount a surface. Call each at most once per +page: a second call logs a warning and does nothing. + +::callout +DeskCrew serves its widget from its own origin and derives its API endpoint from the script's +`src`, so this script does not support [bundling](/docs/guides/bundling) or +[first-party mode](/docs/guides/first-party). It loads directly from `deskcrew.io`. +:: diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index af67135e..0e2e3dec 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -51,6 +51,10 @@ export const LOGOS = { light: ``, dark: ``, }, + deskcrew: { + light: ``, + dark: ``, + }, crisp: { light: ``, dark: ``, diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index f0ed669a..67ab823c 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -238,6 +238,38 @@ "code": "export interface DatabuddyAnalyticsApi {\n /**\n * Track a custom event.\n * @param eventName Name of the event (use snake_case)\n * @param properties Optional event properties\n */\n track: (eventName: string, properties?: Record) => Promise | any | void\n\n /**\n * Manually record a page / screen view. Useful for SPA route changes.\n * @param path Optional path to record (defaults to current location)\n * @param properties Optional additional properties for the screen view\n */\n screenView: (path?: string, properties?: Record) => void\n\n /**\n * Set properties that will be attached to all future events (e.g. user_id).\n * @param properties Key/value map of properties to attach globally\n */\n setGlobalProperties: (properties: Record) => void\n\n /**\n * Track a custom event alias (compatibility helper present on the global)\n * @param eventName Name of the event\n * @param properties Optional event properties\n */\n trackCustomEvent: (eventName: string, properties?: Record) => void\n\n /**\n * Clears session and anonymous identifiers (useful on logout).\n */\n clear: () => void\n\n /**\n * Force immediate sending of any queued/batched events.\n */\n flush: () => void\n}" } ], + "deskcrew": [ + { + "name": "DeskCrewOptions", + "kind": "const", + "code": "export const DeskCrewOptions = object({\n /**\n * The DeskCrew public widget key, for example `pub_xxxxxxxx`.\n * Rendered as the `data-key` attribute. Find it in the DeskCrew dashboard under Install.\n * @see https://deskcrew.io/integrations/nuxt\n */\n // minLength(1) rather than a bare string(): envDefaults resolves an unset key to '',\n // which would validate happily and render data-key=\"\" onto the tag, booting a keyless\n // widget that silently talks to no workspace. Failing loudly in dev is the point.\n widgetKey: pipe(string(), minLength(1)),\n /**\n * The workspace board slug, lowercase letters, numbers and dashes only.\n * Rendered as the `data-board` attribute. Required for the help centre, changelog and\n * embedded portal surfaces; the chat launcher works without it.\n */\n board: optional(string()),\n /**\n * Accent colour as a 6 digit hex value, for example `#4f46e5`.\n * Rendered as the `data-color` attribute. Overrides the accent configured on the workspace.\n */\n color: optional(string()),\n /**\n * Which side of the viewport the launcher sits on.\n * Rendered as the `data-position` attribute.\n * @default 'right'\n */\n position: optional(union([literal('left'), literal('right')])),\n /**\n * Greeting text shown in the widget header, overriding the workspace default.\n * Rendered as the `data-greeting` attribute.\n */\n greeting: optional(string()),\n /**\n * Render the workspace logo in the launcher bubble instead of the default icon.\n * Rendered as the `data-launcher` attribute.\n */\n launcher: optional(literal('logo')),\n})" + }, + { + "name": "DeskCrewIdentity", + "kind": "interface", + "code": "export interface DeskCrewIdentity {\n /** A signed identity token minted by your backend. */\n token: string\n}" + }, + { + "name": "DeskCrewEmbedOptions", + "kind": "interface", + "code": "export interface DeskCrewEmbedOptions {\n /** Target element, or a selector for it. */\n el: string | HTMLElement\n /** Board slug, defaults to the `board` option. */\n board?: string\n /** Path the portal is mounted under, defaults to the current pathname. */\n basePath?: string\n /** Which portal view to open first. */\n view?: string\n theme?: 'light' | 'dark'\n}" + }, + { + "name": "DeskCrewChangelogOptions", + "kind": "interface", + "code": "export interface DeskCrewChangelogOptions {\n /** `'inline'` requires `selector`. */\n mode?: 'inline' | 'floating'\n selector?: string\n basePath?: string\n position?: 'left' | 'right'\n}" + }, + { + "name": "DeskCrewSurveyOptions", + "kind": "interface", + "code": "export interface DeskCrewSurveyOptions {\n /** Reserved. Surveys are opt in and may only be invoked once per page. */\n [key: string]: unknown\n}" + }, + { + "name": "DeskCrewApi", + "kind": "interface", + "code": "export interface DeskCrewApi {\n /** Open the support panel. */\n open: () => void\n /** Close the support panel. */\n close: () => void\n /** Attach a signed identity token to the current session. */\n identify: (identity: DeskCrewIdentity) => void\n /** Mount the embedded support portal into an element. May only be called once per page. */\n embed: (options: DeskCrewEmbedOptions) => void\n /** Mount the changelog widget. May only be called once per page. */\n changelog: (options?: DeskCrewChangelogOptions) => void\n /** Show an eligible survey. Opt in, may only be called once per page. */\n surveys: (options?: DeskCrewSurveyOptions) => void\n /** Report an error to Signals. No-op unless the workspace has error capture enabled. */\n captureError: (error: unknown, context?: Record) => void\n}" + } + ], "fathom-analytics": [ { "name": "FathomAnalyticsOptions", @@ -1763,6 +1795,45 @@ "description": "Enable error tracking via observability." } ], + "DeskCrewOptions": [ + { + "name": "widgetKey", + "type": "string", + "required": true, + "description": "The DeskCrew public widget key, for example `pub_xxxxxxxx`. Rendered as the `data-key` attribute. Find it in the DeskCrew dashboard under Install." + }, + { + "name": "board", + "type": "string", + "required": false, + "description": "The workspace board slug, lowercase letters, numbers and dashes only. Rendered as the `data-board` attribute. Required for the help centre, changelog and embedded portal surfaces; the chat launcher works without it." + }, + { + "name": "color", + "type": "string", + "required": false, + "description": "Accent colour as a 6 digit hex value, for example `#4f46e5`. Rendered as the `data-color` attribute. Overrides the accent configured on the workspace." + }, + { + "name": "position", + "type": "'left' | 'right'", + "required": false, + "description": "Which side of the viewport the launcher sits on. Rendered as the `data-position` attribute.", + "defaultValue": "'right'" + }, + { + "name": "greeting", + "type": "string", + "required": false, + "description": "Greeting text shown in the widget header, overriding the workspace default. Rendered as the `data-greeting` attribute." + }, + { + "name": "launcher", + "type": "'logo'", + "required": false, + "description": "Render the workspace logo in the launcher bubble instead of the default icon. Rendered as the `data-launcher` attribute." + } + ], "FathomAnalyticsOptions": [ { "name": "site", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index d26ab79b..ed79bd24 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -21,6 +21,7 @@ import { CloudflareWebAnalyticsOptions, CrispOptions, DatabuddyAnalyticsOptions, + DeskCrewOptions, FathomAnalyticsOptions, GoogleAdsenseOptions, GoogleAnalyticsOptions, @@ -168,6 +169,7 @@ export const registryMeta: RegistryScriptMeta[] = [ // support 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), // cdn m('npm', 'NPM', 'cdn', 'useScriptNpm', { bundle: true }, null), // utility @@ -749,6 +751,18 @@ export async function registry(resolve?: (path: string) => Promise): Pro envDefaults: { id: '' }, bundle: true, }), + // DeskCrew serves one mutable bundle from its own origin and derives its API + // origin from the executing script's own `src`. Bundling to /_scripts/assets + // would repoint every widget API call at the consuming site's origin, and + // proxying would put the consuming site's server in the path of live chat + // polling. Both capabilities are therefore intentionally absent. + def('deskcrew', { + schema: DeskCrewOptions, + label: 'DeskCrew', + category: 'support', + composableName: 'useScriptDeskCrew', + envDefaults: { widgetKey: '', board: '' }, + }), // cdn def('npm', { schema: NpmOptions, diff --git a/packages/script/src/runtime/components/ScriptDeskCrew.vue b/packages/script/src/runtime/components/ScriptDeskCrew.vue new file mode 100644 index 00000000..91b82867 --- /dev/null +++ b/packages/script/src/runtime/components/ScriptDeskCrew.vue @@ -0,0 +1,109 @@ + + + diff --git a/packages/script/src/runtime/registry/deskcrew.ts b/packages/script/src/runtime/registry/deskcrew.ts new file mode 100644 index 00000000..3650e66a --- /dev/null +++ b/packages/script/src/runtime/registry/deskcrew.ts @@ -0,0 +1,89 @@ +import type { RegistryScriptInput } from '#nuxt-scripts/types' +import { useRegistryScript } from '../utils' +import { DeskCrewOptions } from './schemas' + +export { DeskCrewOptions } + +export type DeskCrewInput = RegistryScriptInput + +export interface DeskCrewIdentity { + /** A signed identity token minted by your backend. */ + token: string +} + +export interface DeskCrewEmbedOptions { + /** Target element, or a selector for it. */ + el: string | HTMLElement + /** Board slug, defaults to the `board` option. */ + board?: string + /** Path the portal is mounted under, defaults to the current pathname. */ + basePath?: string + /** Which portal view to open first. */ + view?: string + theme?: 'light' | 'dark' +} + +export interface DeskCrewChangelogOptions { + /** `'inline'` requires `selector`. */ + mode?: 'inline' | 'floating' + selector?: string + basePath?: string + position?: 'left' | 'right' +} + +export interface DeskCrewSurveyOptions { + /** Reserved. Surveys are opt in and may only be invoked once per page. */ + [key: string]: unknown +} + +export interface DeskCrewApi { + /** Open the support panel. */ + open: () => void + /** Close the support panel. */ + close: () => void + /** Attach a signed identity token to the current session. */ + identify: (identity: DeskCrewIdentity) => void + /** Mount the embedded support portal into an element. May only be called once per page. */ + embed: (options: DeskCrewEmbedOptions) => void + /** Mount the changelog widget. May only be called once per page. */ + changelog: (options?: DeskCrewChangelogOptions) => void + /** Show an eligible survey. Opt in, may only be called once per page. */ + surveys: (options?: DeskCrewSurveyOptions) => void + /** Report an error to Signals. No-op unless the workspace has error capture enabled. */ + captureError: (error: unknown, context?: Record) => void +} + +declare global { + interface Window { + DeskCrew: DeskCrewApi + } +} + +export function useScriptDeskCrew(_options?: DeskCrewInput) { + return useRegistryScript('deskcrew', options => ({ + scriptInput: { + 'src': 'https://deskcrew.io/desk.js', + 'data-key': options.widgetKey, + // `|| undefined` on every optional: with envDefaults an unset field resolves to + // '', and unhead would then render an empty data-board="" onto the tag. Same + // guard umami-analytics uses. + 'data-board': options?.board || undefined, + 'data-color': options?.color || undefined, + 'data-position': options?.position || undefined, + 'data-greeting': options?.greeting || undefined, + 'data-launcher': options?.launcher || undefined, + }, + schema: import.meta.dev ? DeskCrewOptions : undefined, + scriptOptions: { + // `use()` rather than `resolve({ waitFor })`. desk.js is one synchronous IIFE: + // the queue stub and the real API are assigned at the same nesting level with no + // async boundary between them, so the API exists by the time the load event that + // use() waits on fires. The script also assigns its methods onto the stub rather + // than replacing the object, so window.DeskCrew keeps a single identity for the + // life of the page and a handle taken at any point stays live. + use() { + return window.DeskCrew + }, + }, + }), _options) +} diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 4b3438cf..fc5b13b6 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -290,6 +290,45 @@ export const DatabuddyAnalyticsOptions = object({ enableErrorTracking: optional(boolean()), }) +export const DeskCrewOptions = object({ + /** + * The DeskCrew public widget key, for example `pub_xxxxxxxx`. + * Rendered as the `data-key` attribute. Find it in the DeskCrew dashboard under Install. + * @see https://deskcrew.io/integrations/nuxt + */ + // minLength(1) rather than a bare string(): envDefaults resolves an unset key to '', + // which would validate happily and render data-key="" onto the tag, booting a keyless + // widget that silently talks to no workspace. Failing loudly in dev is the point. + widgetKey: pipe(string(), minLength(1)), + /** + * The workspace board slug, lowercase letters, numbers and dashes only. + * Rendered as the `data-board` attribute. Required for the help centre, changelog and + * embedded portal surfaces; the chat launcher works without it. + */ + board: optional(string()), + /** + * Accent colour as a 6 digit hex value, for example `#4f46e5`. + * Rendered as the `data-color` attribute. Overrides the accent configured on the workspace. + */ + color: optional(string()), + /** + * Which side of the viewport the launcher sits on. + * Rendered as the `data-position` attribute. + * @default 'right' + */ + position: optional(union([literal('left'), literal('right')])), + /** + * Greeting text shown in the widget header, overriding the workspace default. + * Rendered as the `data-greeting` attribute. + */ + greeting: optional(string()), + /** + * Render the workspace logo in the launcher bubble instead of the default icon. + * Rendered as the `data-launcher` attribute. + */ + launcher: optional(literal('logo')), +}) + export const FathomAnalyticsOptions = object({ /** * The Fathom Analytics site ID. diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index de70dd75..edcf317c 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -13,6 +13,7 @@ import type { ClarityInput } from './registry/clarity' import type { CloudflareWebAnalyticsInput } from './registry/cloudflare-web-analytics' import type { CrispInput } from './registry/crisp' import type { DatabuddyAnalyticsInput } from './registry/databuddy-analytics' +import type { DeskCrewInput } from './registry/deskcrew' import type { FathomAnalyticsInput } from './registry/fathom-analytics' import type { GoogleAdsenseInput } from './registry/google-adsense' import type { GoogleAnalyticsInput } from './registry/google-analytics' @@ -254,6 +255,7 @@ export interface ScriptRegistry { carbonAds?: true calendly?: CalendlyInput crisp?: CrispInput + deskcrew?: DeskCrewInput clarity?: ClarityInput cloudflareWebAnalytics?: CloudflareWebAnalyticsInput databuddyAnalytics?: DatabuddyAnalyticsInput @@ -302,7 +304,7 @@ export interface ScriptRegistry { */ export type BuiltInRegistryScriptKey = | 'ahrefsAnalytics' | 'bingUet' | 'blueskyEmbed' | 'calendly' | 'carbonAds' | 'crisp' | 'clarity' | 'cloudflareWebAnalytics' - | 'databuddyAnalytics' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' + | 'databuddyAnalytics' | 'deskcrew' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'leaflet' | 'maplibre' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'matomoAnalytics' diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index ddab9899..1a96ebf1 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -142,6 +142,12 @@ export const scriptMeta = { trackedData: ['user-identity', 'events'], testId: 'akg5rmxb', }, + deskcrew: { + urls: ['https://deskcrew.io/desk.js'], + trackedData: ['user-identity', 'events', 'errors'], + testId: 'pub_deskcrewdemo', + }, + crisp: { urls: ['https://client.crisp.chat/l.js'], trackedData: ['user-identity', 'events'], diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index d599c6e4..93dc2b64 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -39,6 +39,7 @@ export default defineNuxtConfig({ posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, intercom: { app_id: 'akg5rmxb' }, crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, + deskcrew: { widgetKey: 'pub_deskcrewdemo', board: 'demo' }, }, }, }, @@ -107,6 +108,7 @@ export default defineNuxtConfig({ hotjar: { id: 3925006, sv: 6, trigger: 'manual' }, intercom: { app_id: 'akg5rmxb', trigger: 'manual' }, crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417', trigger: 'manual' }, + deskcrew: { widgetKey: 'pub_deskcrewdemo', board: 'demo', trigger: 'manual' }, // Media youtubePlayer: { trigger: 'manual' }, diff --git a/playground/pages/index.vue b/playground/pages/index.vue index 8f99580a..e69ff39b 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -39,6 +39,7 @@ function getPlaygroundPath(script: any): string | null { 'hotjar': '/third-parties/hotjar', 'intercom': '/third-parties/intercom/facade', 'crisp': '/third-parties/crisp/facade', + 'deskcrew': '/third-parties/deskcrew/facade', 'stripe': '/third-parties/stripe/nuxt-scripts', 'paypal': '/third-parties/paypal/nuxt-scripts', 'lemon-squeezy': '/third-parties/lemon-squeezy/component', @@ -131,6 +132,12 @@ const support = registryScripts logo: registryScripts.find(s => s.label === 'Crisp')?.logo, registryScript: null, }, + { + name: 'DeskCrew (Default)', + path: '/third-parties/deskcrew/default', + logo: registryScripts.find(s => s.label === 'DeskCrew')?.logo, + registryScript: null, + }, ]) const payments = registryScripts diff --git a/playground/pages/third-parties/deskcrew/default.vue b/playground/pages/third-parties/deskcrew/default.vue new file mode 100644 index 00000000..fba86362 --- /dev/null +++ b/playground/pages/third-parties/deskcrew/default.vue @@ -0,0 +1,19 @@ + + + diff --git a/playground/pages/third-parties/deskcrew/facade.vue b/playground/pages/third-parties/deskcrew/facade.vue new file mode 100644 index 00000000..498d1d4b --- /dev/null +++ b/playground/pages/third-parties/deskcrew/facade.vue @@ -0,0 +1,45 @@ + + + diff --git a/test/nuxt-runtime/chat-components-lifecycle.nuxt.test.ts b/test/nuxt-runtime/chat-components-lifecycle.nuxt.test.ts index 86205688..82d606e3 100644 --- a/test/nuxt-runtime/chat-components-lifecycle.nuxt.test.ts +++ b/test/nuxt-runtime/chat-components-lifecycle.nuxt.test.ts @@ -2,16 +2,19 @@ import { mountSuspended } from '@nuxt/test-utils/runtime' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { h, nextTick } from 'vue' import ScriptCrisp from '../../packages/script/src/runtime/components/ScriptCrisp.vue' +import ScriptDeskCrew from '../../packages/script/src/runtime/components/ScriptDeskCrew.vue' import ScriptIntercom from '../../packages/script/src/runtime/components/ScriptIntercom.vue' import ScriptLemonSqueezy from '../../packages/script/src/runtime/components/ScriptLemonSqueezy.vue' const mocks = vi.hoisted(() => ({ crispStatus: { __v_isRef: true, value: 'loaded' }, + deskcrewStatus: { __v_isRef: true, value: 'loaded' }, intercomStatus: { __v_isRef: true, value: 'loaded' }, lemonLoadedCallbacks: [] as Array<(api: any) => void>, lemonRefresh: vi.fn(), lemonSetup: vi.fn(), useScriptCrisp: vi.fn(), + useScriptDeskCrew: vi.fn(), useScriptIntercom: vi.fn(), useScriptLemonSqueezy: vi.fn(), useScriptTriggerElement: vi.fn(() => () => {}), @@ -25,6 +28,10 @@ vi.mock('../../packages/script/src/runtime/registry/crisp', () => ({ useScriptCrisp: mocks.useScriptCrisp, })) +vi.mock('../../packages/script/src/runtime/registry/deskcrew', () => ({ + useScriptDeskCrew: mocks.useScriptDeskCrew, +})) + vi.mock('../../packages/script/src/runtime/registry/intercom', () => ({ useScriptIntercom: mocks.useScriptIntercom, })) @@ -41,6 +48,11 @@ describe('chat component lifecycle', () => { status: mocks.crispStatus, onLoaded: vi.fn(), }) + mocks.deskcrewStatus.value = 'loaded' + mocks.useScriptDeskCrew.mockReturnValue({ + status: mocks.deskcrewStatus, + onLoaded: vi.fn(), + }) mocks.useScriptIntercom.mockReturnValue({ status: mocks.intercomStatus, onLoaded: vi.fn(), @@ -58,6 +70,7 @@ describe('chat component lifecycle', () => { afterEach(() => { document.getElementById('crisp-chatbox')?.remove() + document.getElementById('deskcrew-root')?.remove() document.getElementById('intercom-frame')?.remove() delete (window as any).LemonSqueezy vi.clearAllMocks() @@ -80,6 +93,26 @@ describe('chat component lifecycle', () => { expect(wrapper.find('[data-testid="loading"]').exists()).toBe(false) }) + it('recognizes DeskCrew when mounting after the widget host exists', async () => { + // The widget mounts a Shadow DOM host with this id, so its presence is the only + // reliable readiness signal: script load alone fires before anything is rendered, + // which would hide the facade while the screen is still empty. + const host = document.createElement('div') + host.id = 'deskcrew-root' + document.body.append(host) + + const wrapper = await mountSuspended(ScriptDeskCrew, { + props: { widgetKey: 'pub_deskcrewdemo', trigger: 'visible' }, + slots: { + loading: () => h('span', { 'data-testid': 'loading' }), + }, + }) + await nextTick() + + expect(wrapper.emitted('ready')).toHaveLength(1) + expect(wrapper.find('[data-testid="loading"]').exists()).toBe(false) + }) + it('recognizes Intercom when mounting after the shared SDK is ready', async () => { const frame = document.createElement('iframe') frame.id = 'intercom-frame' diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 4288fd52..cdde41b5 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -1,5 +1,6 @@ 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 { 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' @@ -18,6 +19,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() @@ -95,6 +97,21 @@ describe('module options registry', () => { }) describe('registry api types', () => { + it('DeskCrewApi exposes the documented surface with usable signatures', () => { + // The registry hands this object straight to callers, so a regression to `any` + // here would compile fine and silently remove every bit of autocomplete. + expectTypeOf().toMatchTypeOf<() => void>() + expectTypeOf().toMatchTypeOf<() => void>() + expectTypeOf().toMatchTypeOf<(i: { token: string }) => void>() + expectTypeOf().parameter(0).not.toBeAny() + // embed requires a target, and asserting the FULL signature rather than just + // "parameter 0 is not any" is deliberate: the weaker form still passes if the + // parameter later becomes optional, and a portal with no host element renders + // nowhere at all, silently. + expectTypeOf().toMatchTypeOf<(options: DeskCrewEmbedOptions) => void>() + expectTypeOf().parameter(0).toEqualTypeOf() + }) + it('CrispApi preserves literal unions for autocomplete', () => { type IsName = Parameters[0] // Should be assignable to string diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index 407646a3..e819b274 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -393,6 +393,14 @@ describe('proxy configs', () => { expect(config).toBeUndefined() }) + it('does not return proxy config for deskcrew (script derives its API origin from its own src)', async () => { + // Proxying would put the consuming site's server in the path of live chat + // polling, and the widget resolves its API origin from the executing + // script's src, so a proxied URL would repoint every call at the wrong host. + const config = (await getProxyConfigs()).deskcrew + expect(config).toBeUndefined() + }) + it('returns proxy config for calendly', async () => { const config = (await getProxyConfigs()).calendly expect(config).toBeDefined() @@ -446,6 +454,7 @@ describe('proxy configs', () => { expect(configs).not.toHaveProperty('fathomAnalytics') expect(configs).toHaveProperty('intercom') expect(configs).not.toHaveProperty('crisp') + expect(configs).not.toHaveProperty('deskcrew') expect(configs).toHaveProperty('vercelAnalytics') expect(configs).toHaveProperty('gravatar') expect(configs).toHaveProperty('calendly')