Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions docs/content/scripts/deskcrew.md
Original file line number Diff line number Diff line change
@@ -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: "<ScriptDeskCrew>"
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
[`<ScriptDeskCrew>`{lang="html"}](#scriptdeskcrew){lang="html"} for a custom chat launcher.
Comment on lines +18 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix the in-page link fragments.

Markdownlint reports #usescriptdeskcrew and #scriptdeskcrew as invalid fragments. Add explicit stable heading IDs or update the fragments to match the IDs generated by the documentation renderer.

🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 18-18: Link fragments should be valid

(MD051, link-fragments)


[warning] 19-19: Link fragments should be valid

(MD051, link-fragments)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/content/scripts/deskcrew.md` around lines 18 - 19, Update the in-page
links in the documentation sentence to use valid fragments matching the
renderer-generated heading IDs, or add explicit stable IDs to the corresponding
useScriptDeskCrew and ScriptDeskCrew headings. Keep both links targeting their
intended sections.

Source: Linters/SAST tools


::script-stats
::

::script-docs
::

## [`<ScriptDeskCrew>`{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=<YOUR_PUBLIC_KEY>
NUXT_PUBLIC_SCRIPTS_DESKCREW_BOARD=<YOUR_BOARD_SLUG>
```

### 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<T extends DeskCrewApi>(_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
<script setup lang="ts">
const { proxy } = useScriptDeskCrew({ widgetKey: 'pub_xxxxxxxx' })
const { data } = await useFetch('/api/deskcrew-token')
watchEffect(() => {
if (data.value?.token)
proxy.identify({ token: data.value.token })
})
</script>
```

### 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`.
::
4 changes: 4 additions & 0 deletions packages/script/src/registry-logos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export const LOGOS = {
light: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#000" d="M18.205 2.25h3.308l-7.227 8.26l8.502 11.24H16.13l-5.214-6.817L4.95 21.75H1.64l7.73-8.835L1.215 2.25H8.04l4.713 6.231zm-1.161 17.52h1.833L7.045 4.126H5.078z"/></svg>`,
dark: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#fff" d="M18.205 2.25h3.308l-7.227 8.26l8.502 11.24H16.13l-5.214-6.817L4.95 21.75H1.64l7.73-8.835L1.215 2.25H8.04l4.713 6.231zm-1.161 17.52h1.833L7.045 4.126H5.078z"/></svg>`,
},
deskcrew: {
light: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#0f172a"/><path d="M8.5 10.5 12.5 16l-4 5.5" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.5 21.5h8" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/></svg>`,
dark: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#fff"/><path d="M8.5 10.5 12.5 16l-4 5.5" fill="none" stroke="#0f172a" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.5 21.5h8" fill="none" stroke="#0f172a" stroke-width="2.2" stroke-linecap="round"/></svg>`,
},
crisp: {
light: `<svg height="30" width="35" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><filter id="a" height="138.7%" width="131.4%" x="-15.7%" y="-15.1%"><feMorphology in="SourceAlpha" operator="dilate" radius="1" result="shadowSpreadOuter1"/><feOffset dy="1" in="shadowSpreadOuter1" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation="1"/><feComposite in="shadowBlurOuter1" in2="SourceAlpha" operator="out" result="shadowBlurOuter1"/><feColorMatrix in="shadowBlurOuter1" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.07 0"/></filter><path id="b" d="M14.23 20.46l-9.65 1.1L3 5.12 30.07 2l1.58 16.46-9.37 1.07-3.5 5.72-4.55-4.8z"/></defs><g fill="none" fill-rule="evenodd"><use fill="#000" filter="url(#a)" xlink:href="#b"/><use fill="#1972f5" stroke="#1972f5" stroke-width="2" xlink:href="#b"/></g></svg>`,
dark: `<svg height="30" width="35" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><filter id="a" height="138.7%" width="131.4%" x="-15.7%" y="-15.1%"><feMorphology in="SourceAlpha" operator="dilate" radius="1" result="shadowSpreadOuter1"/><feOffset dy="1" in="shadowSpreadOuter1" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation="1"/><feComposite in="shadowBlurOuter1" in2="SourceAlpha" operator="out" result="shadowBlurOuter1"/><feColorMatrix in="shadowBlurOuter1" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.07 0"/></filter><path id="b" d="M14.23 20.46l-9.65 1.1L3 5.12 30.07 2l1.58 16.46-9.37 1.07-3.5 5.72-4.55-4.8z"/></defs><g fill="none" fill-rule="evenodd"><use fill="#000" filter="url(#a)" xlink:href="#b"/><use fill="#fff" stroke="#fff" stroke-width="2" xlink:href="#b"/></g></svg>`,
Expand Down
71 changes: 71 additions & 0 deletions packages/script/src/registry-types.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>) => Promise<any> | 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<string, any>) => 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<string, any>) => 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<string, any>) => 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<string, any>) => void\n}"
}
],
"fathom-analytics": [
{
"name": "FathomAnalyticsOptions",
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions packages/script/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
CloudflareWebAnalyticsOptions,
CrispOptions,
DatabuddyAnalyticsOptions,
DeskCrewOptions,
FathomAnalyticsOptions,
GoogleAdsenseOptions,
GoogleAnalyticsOptions,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -749,6 +751,18 @@ export async function registry(resolve?: (path: string) => Promise<string>): 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,
Expand Down
109 changes: 109 additions & 0 deletions packages/script/src/runtime/components/ScriptDeskCrew.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { ElementScriptTrigger } from '#nuxt-scripts/types'
import { useMutationObserver } from '@vueuse/core'
import { computed, onMounted, ref, shallowRef, watch } from 'vue'
import { useScriptTriggerElement } from '../composables/useScriptTriggerElement'
import { useScriptDeskCrew } from '../registry/deskcrew'

const props = withDefaults(defineProps<{
/**
* Defines the trigger event to load the script.
*/
trigger?: ElementScriptTrigger
widgetKey: string
board?: string
color?: string
position?: 'left' | 'right'
greeting?: string
launcher?: 'logo'
}>(), {
trigger: 'click',
})

const emits = defineEmits<{
ready: [e: ReturnType<typeof useScriptDeskCrew>]
error: []
}>()

defineSlots<{
default?: (props: { ready: boolean }) => any
awaitingLoad?: () => any
loading?: () => any
error?: () => any
}>()

const rootEl = ref(null)
const trigger = useScriptTriggerElement({ trigger: props.trigger, el: rootEl })

const isReady = ref(false)
const deskcrew = useScriptDeskCrew({
widgetKey: props.widgetKey,
board: props.board,
color: props.color,
position: props.position,
greeting: props.greeting,
launcher: props.launcher,
scriptOptions: {
trigger,
},
})
const { onLoaded, status } = deskcrew
if (props.trigger === 'click') {
onLoaded((instance) => {
instance.open()
})
}

defineExpose({
deskcrew,
})

const observerTarget = shallowRef<HTMLElement | null>(null)
function markReady() {
if (isReady.value)
return true
// The widget mounts a Shadow DOM host into document.body with this id, so its
// presence is the only reliable signal that the UI actually exists. Waiting on
// script load alone would flip the facade away before anything is rendered.
if (!document.getElementById('deskcrew-root'))
return false
isReady.value = true
observerTarget.value = null
emits('ready', deskcrew)
return true
}
useMutationObserver(observerTarget, markReady, { childList: true, subtree: true })

onMounted(() => {
watch(status, (status) => {
if (status === 'loaded') {
if (!markReady())
observerTarget.value = document.body
}
else if (status === 'error') {
observerTarget.value = null
isReady.value = false
emits('error')
}
}, { immediate: true })
})

const rootAttrs = computed(() => {
return {
...(trigger instanceof Promise ? trigger.ssrAttrs || {} : {}),
}
})
</script>

<template>
<div
ref="rootEl"
:style="{ display: isReady ? 'none' : 'block' }"
v-bind="rootAttrs"
>
<slot :ready="isReady" />
<slot v-if="status === 'awaitingLoad'" name="awaitingLoad" />
<slot v-else-if="status === 'error'" name="error" />
<slot v-else-if="status === 'loading' || !isReady" name="loading" />
</div>
</template>
Loading
Loading