From b97207116cb1d022e17f6833e27b3c6b420ddabe Mon Sep 17 00:00:00 2001 From: Millions Mind <290682724+webmilmind1@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:00:53 +1000 Subject: [PATCH 1/2] feat(deskcrew): add DeskCrew to the registry DeskCrew is a support widget combining live chat, AI answers grounded in a knowledge base, a help centre and a changelog. Configured by data attributes, so it follows the Umami shape rather than a programmatic init: `data-key` plus optional `board`, `color`, `position`, `greeting` and `launcher`. `|| undefined` guards every optional because with envDefaults an unset field resolves to '' and unhead would render an empty attribute onto the tag. Uses a synchronous `use()` rather than `resolve({ waitFor })`. desk.js is a single 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 fires. The widget also assigns its methods onto the stub instead of replacing the object, so the global keeps one identity for the life of the page and a handle taken before load stays live. `bundle` and `proxy` are deliberately absent, and the type reflects that via RegistryScriptInput<..., false, false>. The widget derives its API origin from the executing script's own `src`, so bundling to /_scripts/assets would repoint every API call at the consuming site's origin, and proxying would put that site's server in the path of live chat polling. A negative test in proxy-configs pins it with the reason. The facade mirrors ScriptCrisp, including its slot-ordering quirk so the two support facades stay identical. Its readiness signal is the widget's Shadow DOM host (#deskcrew-root) rather than script load, because load fires before anything renders and would hide the facade over an empty screen. Identity is deliberately not a schema field. It is a signed per-visitor token, and everything in nuxt.config is a deploy-time constant, so baking one visitor's token into a build would hand that identity to everyone. It goes through proxy.identify({ token }) at runtime, documented on the docs page. --- docs/content/scripts/deskcrew.md | 110 ++++++++++++++++++ packages/script/src/registry-logos.ts | 4 + packages/script/src/registry-types.json | 71 +++++++++++ packages/script/src/registry.ts | 14 +++ .../src/runtime/components/ScriptDeskCrew.vue | 109 +++++++++++++++++ .../script/src/runtime/registry/deskcrew.ts | 89 ++++++++++++++ .../script/src/runtime/registry/schemas.ts | 36 ++++++ packages/script/src/runtime/types.ts | 4 +- packages/script/src/script-meta.ts | 6 + playground/nuxt.config.ts | 2 + playground/pages/index.vue | 7 ++ .../pages/third-parties/deskcrew/default.vue | 19 +++ .../pages/third-parties/deskcrew/facade.vue | 45 +++++++ .../chat-components-lifecycle.nuxt.test.ts | 33 ++++++ test/types/types.test-d.ts | 14 +++ test/unit/proxy-configs.test.ts | 9 ++ 16 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 docs/content/scripts/deskcrew.md create mode 100644 packages/script/src/runtime/components/ScriptDeskCrew.vue create mode 100644 packages/script/src/runtime/registry/deskcrew.ts create mode 100644 playground/pages/third-parties/deskcrew/default.vue create mode 100644 playground/pages/third-parties/deskcrew/facade.vue diff --git a/docs/content/scripts/deskcrew.md b/docs/content/scripts/deskcrew.md new file mode 100644 index 000000000..19500af6d --- /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 af67135e6..0e2e3dec6 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 f0ed669a3..83b9291b7 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 widgetKey: string(),\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 d26ab79bb..ed79bd249 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 000000000..91b82867d --- /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 000000000..3650e66a8 --- /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 4b3438cf2..477446dfa 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -290,6 +290,42 @@ 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 + */ + widgetKey: string(), + /** + * 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 de70dd755..edcf317c7 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 ddab98994..1a96ebf1e 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 d599c6e49..93dc2b641 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 8f99580a4..e69ff39b7 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 000000000..fba86362b --- /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 000000000..498d1d4b6 --- /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 86205688f..82d606e33 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 4288fd524..e381e33b6 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 } 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,18 @@ 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: calling it with nothing is a type error, because a + // portal with no host element silently renders nowhere. + expectTypeOf().parameter(0).not.toBeAny() + }) + 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 407646a3e..e819b274d 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') From cdd725f6790b51015217caf0dff9173d0a25e868 Mon Sep 17 00:00:00 2001 From: Millions Mind <290682724+webmilmind1@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:17:00 +1000 Subject: [PATCH 2/2] fix(deskcrew): reject an empty widgetKey, and pin the embed signature Both from CodeRabbit review on #856. widgetKey was a bare string(), and envDefaults resolves an unset key to '', so an app with the module installed but nothing configured validated happily and rendered data-key="" onto the tag: a keyless widget talking to no workspace, in silence. pipe(string(), minLength(1)) fails it loudly in dev instead, matching the four schemas here that already guard ids this way. The embed type assertion only proved parameter 0 was not `any`, which still passes if the parameter later becomes optional. It now pins the full signature, because a portal with no host element renders nowhere at all and nothing would have caught it. Not taking the third comment: markdownlint MD051 flags the in-page fragments in the docs intro, but those two lines are the same shape crisp.md uses (#usescriptcrisp, #scriptcrisp). The rule does not understand the MDC syntax in those headings, and diverging would make this the only script page written differently. Happy to change it if the docs renderer really does generate different ids. --- packages/script/src/registry-types.json | 2 +- packages/script/src/runtime/registry/schemas.ts | 5 ++++- test/types/types.test-d.ts | 11 +++++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 83b9291b7..67ab823c4 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -242,7 +242,7 @@ { "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 widgetKey: string(),\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})" + "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", diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 477446dfa..fc5b13b67 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -296,7 +296,10 @@ export const DeskCrewOptions = object({ * Rendered as the `data-key` attribute. Find it in the DeskCrew dashboard under Install. * @see https://deskcrew.io/integrations/nuxt */ - widgetKey: string(), + // 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 diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index e381e33b6..cdde41b55 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -1,6 +1,6 @@ import type { ModuleOptions } from '../../packages/script/src/module' import type { CrispApi } from '../../packages/script/src/runtime/registry/crisp' -import type { DeskCrewApi } from '../../packages/script/src/runtime/registry/deskcrew' +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' @@ -104,9 +104,12 @@ describe('registry api types', () => { expectTypeOf().toMatchTypeOf<() => void>() expectTypeOf().toMatchTypeOf<(i: { token: string }) => void>() expectTypeOf().parameter(0).not.toBeAny() - // embed requires a target: calling it with nothing is a type error, because a - // portal with no host element silently renders nowhere. - 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', () => {