Skip to content

Commit f811258

Browse files
authored
feat: DeskCrew (#856)
1 parent 6a084c7 commit f811258

16 files changed

Lines changed: 577 additions & 1 deletion

File tree

‎docs/content/scripts/deskcrew.md‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
---
2+
title: DeskCrew
3+
description: Add a lazy-loaded DeskCrew support widget to your Nuxt app.
4+
links:
5+
- label: useScriptDeskCrew
6+
icon: i-simple-icons-github
7+
to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/deskcrew.ts
8+
size: xs
9+
- label: "<ScriptDeskCrew>"
10+
icon: i-simple-icons-github
11+
to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/components/ScriptDeskCrew.vue
12+
size: xs
13+
---
14+
15+
[DeskCrew](https://deskcrew.io/) is a support widget combining live chat, AI answers from your
16+
knowledge base, a help centre and a changelog.
17+
18+
Use [`useScriptDeskCrew()`{lang="ts"}](#usescriptdeskcrew){lang="ts"} for direct SDK calls, or
19+
[`<ScriptDeskCrew>`{lang="html"}](#scriptdeskcrew){lang="html"} for a custom chat launcher.
20+
21+
::script-stats
22+
::
23+
24+
::script-docs
25+
::
26+
27+
## [`<ScriptDeskCrew>`{lang="html"}](/scripts/deskcrew){lang="html"}
28+
29+
The headless facade holds back the DeskCrew widget until its
30+
[element trigger](/docs/guides/script-triggers#element-event-triggers) fires. It listens for
31+
`click` by default, so a visitor who never opens chat downloads none of the widget.
32+
33+
### Component API
34+
35+
See the [Facade Component API](/docs/guides/facade-components#facade-components-api) for full
36+
props, events, and slots.
37+
38+
#### With environment variables
39+
40+
```ts [nuxt.config.ts]
41+
exportdefaultdefineNuxtConfig({
42+
scripts: {
43+
registry: {
44+
deskcrew: { trigger: 'onNuxtReady' },
45+
}
46+
},
47+
runtimeConfig: {
48+
public: {
49+
scripts: {
50+
deskcrew: {
51+
widgetKey: '', // NUXT_PUBLIC_SCRIPTS_DESKCREW_WIDGET_KEY
52+
board: '', // NUXT_PUBLIC_SCRIPTS_DESKCREW_BOARD
53+
},
54+
},
55+
},
56+
},
57+
})
58+
```
59+
60+
```text [.env]
61+
NUXT_PUBLIC_SCRIPTS_DESKCREW_WIDGET_KEY=<YOUR_PUBLIC_KEY>
62+
NUXT_PUBLIC_SCRIPTS_DESKCREW_BOARD=<YOUR_BOARD_SLUG>
63+
```
64+
65+
### Events
66+
67+
The component emits `ready` once the widget has mounted its launcher, and `error` if the script
68+
fails to load.
69+
70+
### Slots
71+
72+
`awaitingLoad`, `loading`, `error` and the default slot behave as documented for facade
73+
components.
74+
75+
## [`useScriptDeskCrew()`{lang="ts"}](/scripts/deskcrew){lang="ts"}
76+
77+
```ts
78+
exportfunction useScriptDeskCrew<TextendsDeskCrewApi>(_options?:DeskCrewInput) {}
79+
```
80+
81+
::script-types
82+
::
83+
84+
### Identifying a visitor
85+
86+
Identity is a signed token minted by your own backend, so it is a runtime call rather than a
87+
`nuxt.config` option. Everything in `nuxt.config` is a deploy-time constant, and baking one
88+
visitor's token into a build would hand that identity to every other visitor.
89+
90+
```vue
91+
<script setup lang="ts">
92+
const { proxy } = useScriptDeskCrew({ widgetKey: 'pub_xxxxxxxx' })
93+
const { data } = await useFetch('/api/deskcrew-token')
94+
watchEffect(() => {
95+
if (data.value?.token)
96+
proxy.identify({ token: data.value.token })
97+
})
98+
</script>
99+
```
100+
101+
### Other surfaces
102+
103+
`embed()`{lang="ts"}, `changelog()`{lang="ts"} and `surveys()`{lang="ts"} each mount a surface. Call each at most once per
104+
page: a second call logs a warning and does nothing.
105+
106+
::callout
107+
DeskCrew serves its widget from its own origin and derives its API endpoint from the script's
108+
`src`, so this script does not support [bundling](/docs/guides/bundling) or
109+
[first-party mode](/docs/guides/first-party). It loads directly from `deskcrew.io`.
110+
::

‎packages/script/src/registry-logos.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export const LOGOS = {
5151
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>`,
5252
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>`,
5353
},
54+
deskcrew: {
55+
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>`,
56+
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>`,
57+
},
5458
crisp: {
5559
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>`,
5660
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>`,

‎packages/script/src/registry-types.json‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,38 @@
238238
"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}"
239239
}
240240
],
241+
"deskcrew": [
242+
{
243+
"name": "DeskCrewOptions",
244+
"kind": "const",
245+
"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})"
246+
},
247+
{
248+
"name": "DeskCrewIdentity",
249+
"kind": "interface",
250+
"code": "export interface DeskCrewIdentity {\n /** A signed identity token minted by your backend. */\n token: string\n}"
251+
},
252+
{
253+
"name": "DeskCrewEmbedOptions",
254+
"kind": "interface",
255+
"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}"
256+
},
257+
{
258+
"name": "DeskCrewChangelogOptions",
259+
"kind": "interface",
260+
"code": "export interface DeskCrewChangelogOptions {\n /** `'inline'` requires `selector`. */\n mode?: 'inline' | 'floating'\n selector?: string\n basePath?: string\n position?: 'left' | 'right'\n}"
261+
},
262+
{
263+
"name": "DeskCrewSurveyOptions",
264+
"kind": "interface",
265+
"code": "export interface DeskCrewSurveyOptions {\n /** Reserved. Surveys are opt in and may only be invoked once per page. */\n [key: string]: unknown\n}"
266+
},
267+
{
268+
"name": "DeskCrewApi",
269+
"kind": "interface",
270+
"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}"
271+
}
272+
],
241273
"fathom-analytics": [
242274
{
243275
"name": "FathomAnalyticsOptions",
@@ -1763,6 +1795,45 @@
17631795
"description": "Enable error tracking via observability."
17641796
}
17651797
],
1798+
"DeskCrewOptions": [
1799+
{
1800+
"name": "widgetKey",
1801+
"type": "string",
1802+
"required": true,
1803+
"description": "The DeskCrew public widget key, for example `pub_xxxxxxxx`. Rendered as the `data-key` attribute. Find it in the DeskCrew dashboard under Install."
1804+
},
1805+
{
1806+
"name": "board",
1807+
"type": "string",
1808+
"required": false,
1809+
"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."
1810+
},
1811+
{
1812+
"name": "color",
1813+
"type": "string",
1814+
"required": false,
1815+
"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."
1816+
},
1817+
{
1818+
"name": "position",
1819+
"type": "'left' | 'right'",
1820+
"required": false,
1821+
"description": "Which side of the viewport the launcher sits on. Rendered as the `data-position` attribute.",
1822+
"defaultValue": "'right'"
1823+
},
1824+
{
1825+
"name": "greeting",
1826+
"type": "string",
1827+
"required": false,
1828+
"description": "Greeting text shown in the widget header, overriding the workspace default. Rendered as the `data-greeting` attribute."
1829+
},
1830+
{
1831+
"name": "launcher",
1832+
"type": "'logo'",
1833+
"required": false,
1834+
"description": "Render the workspace logo in the launcher bubble instead of the default icon. Rendered as the `data-launcher` attribute."
1835+
}
1836+
],
17661837
"FathomAnalyticsOptions": [
17671838
{
17681839
"name": "site",

‎packages/script/src/registry.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
CloudflareWebAnalyticsOptions,
2222
CrispOptions,
2323
DatabuddyAnalyticsOptions,
24+
DeskCrewOptions,
2425
FathomAnalyticsOptions,
2526
GoogleAdsenseOptions,
2627
GoogleAnalyticsOptions,
@@ -168,6 +169,7 @@ export const registryMeta: RegistryScriptMeta[] = [
168169
// support
169170
m('intercom','Intercom','support','useScriptIntercom',{bundle: true,proxy: true},PRIVACY_IP_ONLY),
170171
m('crisp','Crisp','support','useScriptCrisp',{bundle: true},null),
172+
m('deskcrew','DeskCrew','support','useScriptDeskCrew',{},null),
171173
// cdn
172174
m('npm','NPM','cdn','useScriptNpm',{bundle: true},null),
173175
// utility
@@ -749,6 +751,18 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
749751
envDefaults: {id: ''},
750752
bundle: true,
751753
}),
754+
// DeskCrew serves one mutable bundle from its own origin and derives its API
755+
// origin from the executing script's own `src`. Bundling to /_scripts/assets
756+
// would repoint every widget API call at the consuming site's origin, and
757+
// proxying would put the consuming site's server in the path of live chat
758+
// polling. Both capabilities are therefore intentionally absent.
759+
def('deskcrew',{
760+
schema: DeskCrewOptions,
761+
label: 'DeskCrew',
762+
category: 'support',
763+
composableName: 'useScriptDeskCrew',
764+
envDefaults: {widgetKey: '',board: ''},
765+
}),
752766
// cdn
753767
def('npm',{
754768
schema: NpmOptions,
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<script setup lang="ts">
2+
importtype { ElementScriptTrigger } from'#nuxt-scripts/types'
3+
import { useMutationObserver } from'@vueuse/core'
4+
import { computed, onMounted, ref, shallowRef, watch } from'vue'
5+
import { useScriptTriggerElement } from'../composables/useScriptTriggerElement'
6+
import { useScriptDeskCrew } from'../registry/deskcrew'
7+
8+
const props =withDefaults(defineProps<{
9+
/**
10+
* Defines the trigger event to load the script.
11+
*/
12+
trigger?:ElementScriptTrigger
13+
widgetKey:string
14+
board?:string
15+
color?:string
16+
position?:'left'|'right'
17+
greeting?:string
18+
launcher?:'logo'
19+
}>(), {
20+
trigger: 'click',
21+
})
22+
23+
const emits =defineEmits<{
24+
ready: [e: ReturnType<typeofuseScriptDeskCrew>]
25+
error: []
26+
}>()
27+
28+
defineSlots<{
29+
default?: (props: { ready:boolean }) =>any
30+
awaitingLoad?: () =>any
31+
loading?: () =>any
32+
error?: () =>any
33+
}>()
34+
35+
const rootEl =ref(null)
36+
const trigger =useScriptTriggerElement({ trigger: props.trigger, el: rootEl })
37+
38+
const isReady =ref(false)
39+
const deskcrew =useScriptDeskCrew({
40+
widgetKey: props.widgetKey,
41+
board: props.board,
42+
color: props.color,
43+
position: props.position,
44+
greeting: props.greeting,
45+
launcher: props.launcher,
46+
scriptOptions: {
47+
trigger,
48+
},
49+
})
50+
const { onLoaded, status } =deskcrew
51+
if (props.trigger==='click') {
52+
onLoaded((instance) => {
53+
instance.open()
54+
})
55+
}
56+
57+
defineExpose({
58+
deskcrew,
59+
})
60+
61+
const observerTarget =shallowRef<HTMLElement|null>(null)
62+
function markReady() {
63+
if (isReady.value)
64+
returntrue
65+
// The widget mounts a Shadow DOM host into document.body with this id, so its
66+
// presence is the only reliable signal that the UI actually exists. Waiting on
67+
// script load alone would flip the facade away before anything is rendered.
68+
if (!document.getElementById('deskcrew-root'))
69+
returnfalse
70+
isReady.value=true
71+
observerTarget.value=null
72+
emits('ready', deskcrew)
73+
returntrue
74+
}
75+
useMutationObserver(observerTarget, markReady, { childList: true, subtree: true })
76+
77+
onMounted(() => {
78+
watch(status, (status) => {
79+
if (status==='loaded') {
80+
if (!markReady())
81+
observerTarget.value=document.body
82+
}
83+
elseif (status==='error') {
84+
observerTarget.value=null
85+
isReady.value=false
86+
emits('error')
87+
}
88+
}, { immediate: true })
89+
})
90+
91+
const rootAttrs =computed(() => {
92+
return {
93+
...(triggerinstanceofPromise?trigger.ssrAttrs|| {} : {}),
94+
}
95+
})
96+
</script>
97+
98+
<template>
99+
<div
100+
ref="rootEl"
101+
:style="{ display: isReady ? 'none' : 'block' }"
102+
v-bind="rootAttrs"
103+
>
104+
<slot:ready="isReady" />
105+
<slotv-if="status === 'awaitingLoad'"name="awaitingLoad" />
106+
<slotv-else-if="status === 'error'"name="error" />
107+
<slotv-else-if="status === 'loading' || !isReady"name="loading" />
108+
</div>
109+
</template>

0 commit comments

Comments
 (0)