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
1 change: 1 addition & 0 deletions docs/content/docs/1.guides/6.cors.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ Scripts with `crossorigin: false` include:
- Calendly
- Matomo Analytics
- Bing UET
- Deacon

If a registry script fails at the network layer, inspect these attributes alongside the response's CORS headers.

Expand Down
95 changes: 95 additions & 0 deletions docs/content/scripts/deacon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
title: Deacon
description: Add the Deacon chat widget, an AI support agent that answers from your own content, to your Nuxt app.
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/deacon.ts
size: xs
---

[Deacon](https://heydeacon.com) is an AI support agent for websites and apps that answers questions from your own content. The same script also counts page views, goals and JavaScript errors for its website analytics.

[`useScriptDeacon()`{lang="ts"}](/scripts/deacon) loads the widget and types the `window.Deacon` API it sets up.

::script-stats
::

::script-docs
::

Your key is the `data-key` value in the snippet on the Install page of the Deacon dashboard, and starts with `pk_`. Deacon only runs on the sites listed on that page. A listed domain covers its subdomains, and once the widget has loaded on `localhost`, Install offers to add it in one click.

::code-group

```text [.env]
NUXT_PUBLIC_SCRIPTS_DEACON_KEY=pk_your_widget_key
```

```ts [nuxt.config.ts]
export default defineNuxtConfig({
scripts: {
registry: {
deacon: { trigger: 'onNuxtReady' },
},
},
})
```

::

The widget brings its own chat button. To use one of yours, hide Deacon's in Appearance in the dashboard and open the chat from your own button:

```vue
<script setup lang="ts">
const { proxy } = useScriptDeacon()
</script>

<template>
<button @click="proxy.open()">
Ask a question
</button>
</template>
```

## Logged-in visitors

In an app people log in to, pass Deacon the details of the person using it. The team then sees their name on the conversation, and they don't have to type their email to get a reply. Deacon doesn't verify these details. Pass `null` when they log out.

```ts
const { proxy } = useScriptDeacon()
const user = useUser() // your own auth state

watch(user, (current) => {
proxy.identify(current ? { id: current.id, email: current.email, name: current.name } : null)
}, { immediate: true })
```

## Context

`context` passes details Deacon takes into account when it answers, such as the visitor's plan. Everything in `nuxt.config` is the same for every visitor, so pass details about one visitor at runtime with `setContext()`{lang="ts"}, which merges over `context`. A `null` value removes a key. Every value can be read in the page, so never pass a secret.

```ts
const { proxy } = useScriptDeacon()

proxy.setContext({ plan: 'pro', seats: 3 })
```

## Goals

`track()`{lang="ts"} sends a goal to Analytics › Website in Deacon.

```ts
const { proxy } = useScriptDeacon()

proxy.track('Upgraded', { plan: 'pro' })
```

Set `pageViews: false` to send Deacon no page views or JavaScript errors from your app, as you might in an app behind a login. Goals and chat still work.

::script-types
::

::callout
Deacon's loader takes the origin of its own `src` as the address of its chat and API, so this script doesn't support [bundling or first-party mode](/docs/guides/first-party). It loads directly from `heydeacon.com`, with [`crossorigin` turned off](/docs/guides/cors#registry-scripts) because the loader is served without CORS headers.
::
4 changes: 4 additions & 0 deletions packages/script/src/registry-logos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export const LOGOS = {
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>`,
},
tawkTo: `https://www.tawk.to/icon.png`,
deacon: {
light: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 21.9 23.1"><path fill="none" stroke="#2e4145" stroke-width="2" d="M9.4255 1.40666C10.3572 0.865402 11.5076 0.865403 12.4394 1.40666L18.9071 5.1639C19.8314 5.70084 20.4002 6.68903 20.4002 7.75796V15.3045C20.4002 16.3735 19.8314 17.3617 18.9071 17.8986L12.4394 21.6558C11.5076 22.1971 10.3572 22.1971 9.4255 21.6558L2.95773 17.8986C2.03344 17.3617 1.46467 16.3735 1.46467 15.3045V7.75796C1.46467 6.68903 2.03344 5.70084 2.95773 5.1639L9.4255 1.40666Z"/><path fill="#2e4145" d="M9.99049 4.626C10.6241 4.24553 11.416 4.24553 12.0496 4.626L16.3169 7.18827C16.919 7.54979 17.2873 8.20063 17.2873 8.90292V14.1653C17.2873 14.8676 16.919 15.5185 16.3169 15.88L12.0496 18.4423C11.416 18.8227 10.6241 18.8227 9.99049 18.4423L5.7232 15.88C5.12111 15.5185 4.75276 14.8676 4.75276 14.1653V8.90292C4.75276 8.20063 5.12111 7.54979 5.7232 7.18827L9.99049 4.626Z"/></svg>`,
dark: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 21.9 23.1"><path fill="none" stroke="#fff" stroke-width="2" d="M9.4255 1.40666C10.3572 0.865402 11.5076 0.865403 12.4394 1.40666L18.9071 5.1639C19.8314 5.70084 20.4002 6.68903 20.4002 7.75796V15.3045C20.4002 16.3735 19.8314 17.3617 18.9071 17.8986L12.4394 21.6558C11.5076 22.1971 10.3572 22.1971 9.4255 21.6558L2.95773 17.8986C2.03344 17.3617 1.46467 16.3735 1.46467 15.3045V7.75796C1.46467 6.68903 2.03344 5.70084 2.95773 5.1639L9.4255 1.40666Z"/><path fill="#fff" d="M9.99049 4.626C10.6241 4.24553 11.416 4.24553 12.0496 4.626L16.3169 7.18827C16.919 7.54979 17.2873 8.20063 17.2873 8.90292V14.1653C17.2873 14.8676 16.919 15.5185 16.3169 15.88L12.0496 18.4423C11.416 18.8227 10.6241 18.8227 9.99049 18.4423L5.7232 15.88C5.12111 15.5185 4.75276 14.8676 4.75276 14.1653V8.90292C4.75276 8.20063 5.12111 7.54979 5.7232 7.18827L9.99049 4.626Z"/></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
38 changes: 38 additions & 0 deletions packages/script/src/registry-types.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,23 @@
"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}"
}
],
"deacon": [
{
"name": "DeaconOptions",
"kind": "const",
"code": "export const DeaconOptions = object({\n /**\n * Your Deacon key, the `data-key` value in the snippet on the Install page of the\n * Deacon dashboard. It starts with `pk_`.\n * @see https://heydeacon.com/resources/how-to-use-deacon\n */\n key: pipe(string(), minLength(1)),\n /**\n * Details about the visitor that Deacon takes into account when it answers, such as\n * their plan. Rendered as the `data-context` attribute. Up to 12 keys. Every value can be\n * read in the page, so never put a secret here. Use `proxy.setContext()` for details the\n * page learns after it loads.\n */\n context: optional(record(string(), union([string(), number(), boolean()]))),\n /**\n * Set to `false` to send Deacon no page views or JavaScript errors from the page. Chat\n * and goals still work. Rendered as the `data-no-page-views` attribute.\n * @default true\n */\n pageViews: optional(boolean()),\n})"
},
{
"name": "DeaconUser",
"kind": "interface",
"code": "export interface DeaconUser {\n /** Your app's id for them. */\n id?: string | number\n email?: string\n name?: string\n}"
},
{
"name": "DeaconApi",
"kind": "interface",
"code": "export interface DeaconApi {\n /** Open the chat. */\n open: () => void\n /** Close the chat. */\n close: () => void\n /** Open the chat if it is closed, and close it if it is open. */\n toggle: () => void\n /** Load the chat window in the background, so it opens without a wait. */\n preload: () => void\n /**\n * Tell Deacon what the page has learned about the visitor since it loaded, such as their\n * plan. Merges over `context`. A null value removes that key, and `null` clears it all.\n */\n setContext: (values: Record<string, string | number | boolean | null> | null) => void\n /**\n * Tell Deacon who is logged in, so the team sees their name on the conversation and they\n * don't have to type their email to get a reply. Replaces the whole identity, and `null`\n * says nobody is logged in. Deacon does not verify it.\n */\n identify: (user: DeaconUser | null) => void\n /** Send a goal, one of the moments named on Analytics › Website, with optional details. */\n track: (name: string, props?: Record<string, string | number | boolean>) => void\n}"
}
],
"deskcrew": [
{
"name": "DeskCrewOptions",
Expand Down Expand Up @@ -1930,6 +1947,27 @@
"description": "Enable error tracking via observability."
}
],
"DeaconOptions": [
{
"name": "key",
"type": "string",
"required": true,
"description": "Your Deacon key, the `data-key` value in the snippet on the Install page of the Deacon dashboard. It starts with `pk_`."
},
{
"name": "context",
"type": "Record<string, string | number | boolean>",
"required": false,
"description": "Details about the visitor that Deacon takes into account when it answers, such as their plan. Rendered as the `data-context` attribute. Up to 12 keys. Every value can be read in the page, so never put a secret here. Use `proxy.setContext()` for details the page learns after it loads."
},
{
"name": "pageViews",
"type": "boolean",
"required": false,
"description": "Set to `false` to send Deacon no page views or JavaScript errors from the page. Chat and goals still work. Rendered as the `data-no-page-views` attribute.",
"defaultValue": "true"
}
],
"DeskCrewOptions": [
{
"name": "widgetKey",
Expand Down
12 changes: 12 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,
DeaconOptions,
DeskCrewOptions,
FathomAnalyticsOptions,
GoogleAdsenseOptions,
Expand Down Expand Up @@ -182,6 +183,7 @@ export const registryMeta: RegistryScriptMeta[] = [
m('crisp', 'Crisp', 'support', 'useScriptCrisp', { bundle: true }, null),
m('deskcrew', 'DeskCrew', 'support', 'useScriptDeskCrew', {}, null),
m('tawkTo', 'Tawk.to', 'support', 'useScriptTawkTo', {}, null),
m('deacon', 'Deacon', 'support', 'useScriptDeacon', {}, null),
// cdn
m('npm', 'NPM', 'cdn', 'useScriptNpm', { bundle: true }, null),
// utility
Expand Down Expand Up @@ -799,6 +801,16 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
category: 'support',
envDefaults: { propertyId: '', widgetId: '' },
}),
// Deacon's loader takes the origin of its own `src` as the host for its chat frame
// and API, so a copy served from /_scripts/assets would point every request at the
// consuming site, and proxying would put that site's server between visitors and
// the live chat. Bundle and proxy are therefore intentionally absent.
def('deacon', {
schema: DeaconOptions,
label: 'Deacon',
category: 'support',
envDefaults: { key: '' },
}),
// cdn
def('npm', {
schema: NpmOptions,
Expand Down
68 changes: 68 additions & 0 deletions packages/script/src/runtime/registry/deacon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { RegistryScriptInput } from '#nuxt-scripts/types'
import { useRegistryScript } from '../utils'
import { DeaconOptions } from './schemas'

export { DeaconOptions }

export type DeaconInput = RegistryScriptInput<typeof DeaconOptions, false, false>

export interface DeaconUser {
/** Your app's id for them. */
id?: string | number
email?: string
name?: string
}

export interface DeaconApi {
/** Open the chat. */
open: () => void
/** Close the chat. */
close: () => void
/** Open the chat if it is closed, and close it if it is open. */
toggle: () => void
/** Load the chat window in the background, so it opens without a wait. */
preload: () => void
/**
* Tell Deacon what the page has learned about the visitor since it loaded, such as their
* plan. Merges over `context`. A null value removes that key, and `null` clears it all.
*/
setContext: (values: Record<string, string | number | boolean | null> | null) => void
/**
* Tell Deacon who is logged in, so the team sees their name on the conversation and they
* don't have to type their email to get a reply. Replaces the whole identity, and `null`
* says nobody is logged in. Deacon does not verify it.
*/
identify: (user: DeaconUser | null) => void
/** Send a goal, one of the moments named on Analytics › Website, with optional details. */
track: (name: string, props?: Record<string, string | number | boolean>) => void
}

declare global {
interface Window {
Deacon: DeaconApi
}
}

export function useScriptDeacon<T extends DeaconApi>(_options?: DeaconInput) {
return useRegistryScript<T, typeof DeaconOptions>('deacon', options => ({
scriptInput: {
'src': 'https://heydeacon.com/widget/v1/loader.js',
'data-key': options.key,
'data-context': options.context ? JSON.stringify(options.context) : undefined,
// The loader checks hasAttribute(), and unhead renders `false` as "false", so the
// opt-out is written as '' or left out, never as a boolean.
'data-no-page-views': options.pageViews === false ? '' : undefined,
// loader.js is served without an Access-Control-Allow-Origin header, so the
// crossorigin="anonymous" unhead adds by default would have the browser block it.
'crossorigin': false,
},
schema: import.meta.dev ? DeaconOptions : undefined,
scriptOptions: {
// The loader assigns window.Deacon synchronously as it runs, so the API exists
// by the time the load event fires.
use() {
return window.Deacon
},
},
}), _options)
}
22 changes: 22 additions & 0 deletions packages/script/src/runtime/registry/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,28 @@ export const DatabuddyAnalyticsOptions = object({
enableErrorTracking: optional(boolean()),
})

export const DeaconOptions = object({
/**
* Your Deacon key, the `data-key` value in the snippet on the Install page of the
* Deacon dashboard. It starts with `pk_`.
* @see https://heydeacon.com/resources/how-to-use-deacon
*/
key: pipe(string(), minLength(1)),
/**
* Details about the visitor that Deacon takes into account when it answers, such as
* their plan. Rendered as the `data-context` attribute. Up to 12 keys. Every value can be
* read in the page, so never put a secret here. Use `proxy.setContext()` for details the
* page learns after it loads.
*/
context: optional(record(string(), union([string(), number(), boolean()]))),
/**
* Set to `false` to send Deacon no page views or JavaScript errors from the page. Chat
* and goals still work. Rendered as the `data-no-page-views` attribute.
* @default true
*/
pageViews: optional(boolean()),
})

export const DeskCrewOptions = object({
/**
* The DeskCrew public widget key, for example `pub_xxxxxxxx`.
Expand Down
4 changes: 3 additions & 1 deletion packages/script/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { DeaconInput } from './registry/deacon'
import type { DeskCrewInput } from './registry/deskcrew'
import type { FathomAnalyticsInput } from './registry/fathom-analytics'
import type { GoogleAdsenseInput } from './registry/google-adsense'
Expand Down Expand Up @@ -281,6 +282,7 @@ export interface ScriptRegistry {
carbonAds?: true
calendly?: CalendlyInput
crisp?: CrispInput
deacon?: DeaconInput
deskcrew?: DeskCrewInput
clarity?: ClarityInput
cloudflareWebAnalytics?: CloudflareWebAnalyticsInput
Expand Down Expand Up @@ -333,7 +335,7 @@ export interface ScriptRegistry {
*/
export type BuiltInRegistryScriptKey
= | 'ahrefsAnalytics' | 'bingUet' | 'blueskyEmbed' | 'calendly' | 'carbonAds' | 'crisp' | 'clarity' | 'cloudflareWebAnalytics'
| 'databuddyAnalytics' | 'deskcrew' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed'
| 'databuddyAnalytics' | 'deacon' | 'deskcrew' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed'
| 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'leaflet' | 'maplibre'
| 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager'
| 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'pulseAnalytics' | 'matomoAnalytics'
Expand Down
4 changes: 4 additions & 0 deletions packages/script/src/script-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ export const scriptMeta = {
trackedData: ['user-identity', 'events'],
testId: '68496650ddf9cd19094b4530',
},
deacon: {
urls: ['https://heydeacon.com/widget/v1/loader.js'],
trackedData: ['page-views', 'events', 'conversions', 'user-identity', 'errors'],
},

crisp: {
urls: ['https://client.crisp.chat/l.js'],
Expand Down
Loading
Loading