diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md index b67e482c..1f98b357 100644 --- a/FIRST_PARTY.md +++ b/FIRST_PARTY.md @@ -113,7 +113,7 @@ Four presets in `proxy-configs.ts` cover all proxy-enabled scripts: | `PRIVACY_NONE` | all false | (not currently assigned to any script) | | `PRIVACY_FULL` | all true | Meta, TikTok, X, Snap, Reddit, LinkedIn | | `PRIVACY_HEATMAP` | ip, language, hardware | GA, Clarity, Hotjar | -| `PRIVACY_IP_ONLY` | ip only | PostHog, Plausible, Umami, Rybbit, Databuddy, Ahrefs, Fathom, CF Web Analytics, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo | +| `PRIVACY_IP_ONLY` | ip only | PostHog, Plausible, Umami, Rybbit, Databuddy, Ahrefs, Fathom, CF Web Analytics, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo, Calendly | Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capability), so no privacy transforms are applied. @@ -146,6 +146,7 @@ Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capa | `vimeoPlayer` | vimeoPlayer | `PRIVACY_IP_ONLY` | Path A | | `intercom` | intercom | `PRIVACY_IP_ONLY` | Path A | | `gravatar` | gravatar | `PRIVACY_IP_ONLY` | Path A | +| `calendly` | calendly | `PRIVACY_IP_ONLY` | Path A | | `googleTagManager` | googleTagManager | n/a | Bundle only | | `segment` | segment | n/a | Bundle only | | `crisp` | crisp | n/a | Bundle only | diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 4aef3436..22ba1cc5 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -63,7 +63,7 @@ Every proxied script defaults to a privacy tier based on what level of anonymisa |------|-------------------|---------| | **Full** | IP, user agent, language, screen, timezone, hardware fingerprints | Meta Pixel, TikTok Pixel, X Pixel, Snapchat Pixel, Reddit Pixel, LinkedIn Insight Tag | | **Heatmap-safe** | IP, language, hardware fingerprints (preserves screen and user agent for session replay) | Google Analytics, Microsoft Clarity, Hotjar | -| **IP only** | IP addresses anonymised to subnet level | Plausible, PostHog, Umami, Fathom, CF Web Analytics, Vercel Analytics, Rybbit, Databuddy, Matomo, Ahrefs Web Analytics, Intercom, YouTube, Vimeo, Gravatar, Carbon Ads, Lemon Squeezy, Google AdSense | +| **IP only** | IP addresses anonymised to subnet level | Plausible, PostHog, Umami, Fathom, CF Web Analytics, Vercel Analytics, Rybbit, Databuddy, Matomo, Ahrefs Web Analytics, Intercom, YouTube, Vimeo, Gravatar, Carbon Ads, Lemon Squeezy, Google AdSense, Calendly | Sensitive headers (`cookie`, `authorization`) are **always** stripped regardless of tier. diff --git a/docs/content/scripts/calendly.md b/docs/content/scripts/calendly.md new file mode 100644 index 00000000..2763d20a --- /dev/null +++ b/docs/content/scripts/calendly.md @@ -0,0 +1,169 @@ +--- +title: Calendly +description: Embed Calendly bookings in your Nuxt app with inline, popup, and badge widgets. +links: + - label: useScriptCalendly + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/calendly.ts + size: xs + - label: "" + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/components/ScriptCalendlyInlineWidget.vue + size: xs +--- + +[Calendly](https://calendly.com) is a scheduling tool that lets visitors book time on your calendar without back-and-forth emails. The Calendly embed widget renders the booking flow inline, in a popup, or behind a floating badge button. + +Nuxt Scripts provides a registry script composable [`useScriptCalendly()`{lang="ts"}](/scripts/calendly) and a headless [``{lang="html"}](/scripts/calendly){lang="html"} component to integrate it in your Nuxt app. + +::script-stats +:: + +::script-docs +:: + +The composable comes with the following defaults: +- **Trigger: Client** Script will load when Nuxt is hydrating. +- **Stylesheet: Inline** The widget stylesheet (and its close-icon SVG) is inlined on first use, so no IP leak to `assets.calendly.com` on render. + +You can access the `Calendly` global as a proxy directly or await `onLoaded` to use it. Recommended to use the proxy for void calls; `onLoaded` is convenient when you need a stable DOM reference. + +::code-group + +```ts [Proxy] +const { proxy } = useScriptCalendly() +function openBooking() { + proxy.Calendly.initPopupWidget({ + url: 'https://calendly.com/your-name/30min', + }) +} +``` + +```ts [onLoaded] +const { onLoaded } = useScriptCalendly() +onLoaded(({ Calendly }) => { + Calendly.initInlineWidget({ + url: 'https://calendly.com/your-name/30min', + parentElement: document.getElementById('calendly-inline')!, + }) +}) +``` + +:: + +## [``{lang="html"}](/scripts/calendly){lang="html"} + +The [``{lang="html"}](/scripts/calendly){lang="html"} component wraps [`useScriptCalendly()`{lang="ts"}](/scripts/calendly){lang="ts"} for the most common embed shape: an inline booking flow mounted into a host element you control. + +It's optimized for performance by using [Element Event Triggers](/docs/guides/script-triggers#element-event-triggers), only loading the Calendly widget script once the host element comes into view. By default the trigger is `'visible'`. + +```vue + + + +``` + +### Above-the-fold loading + +If the widget is above the fold and you want it to start loading on hydration rather than on visibility, set `above-the-fold` (adds a preconnect to `calendly.com`) and override the trigger. + +```vue + +``` + +### Prefill, UTM, and page settings + +```vue + +``` + +### Slots + +The component exposes `loading`, `awaitingLoad`, and `error` slots for placeholder UX while the script trigger waits or the script load fails. The default `loading` slot renders an accessible spinner. + +## Popup and badge widgets + +Popup and badge modes have no host element, so they're driven from the composable directly: + +::code-group + +```ts [Popup] +const { proxy } = useScriptCalendly() +function open() { + proxy.Calendly.initPopupWidget({ + url: 'https://calendly.com/your-name/30min', + }) +} +``` + +```ts [Badge] +const { onLoaded } = useScriptCalendly() +onLoaded(({ Calendly }) => { + Calendly.initBadgeWidget({ + url: 'https://calendly.com/your-name/30min', + text: 'Schedule time with me', + color: '#0069ff', + textColor: '#ffffff', + }) +}) +``` + +:: + +## Prefilling invitee details and UTM parameters + +All four widget initialisers (`initInlineWidget`, `initPopupWidget`, `initBadgeWidget`, `initPopupWidgetWithText`) accept `prefill` and `utm` options to pre-populate the booking form and tag the booking with marketing attribution. + +```vue + +``` + +::script-types +:: + +## Example + +Loading Calendly through `app.vue` when Nuxt is ready, with the inline widget rendered on a booking page. + +```vue [app.vue] + +``` diff --git a/package.json b/package.json index 36f1d514..25ca6564 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dev": "nuxt dev playground", "dev:ssl": "nuxt dev playground --https", "dev:prepare": "pnpm -r dev:prepare && nuxt prepare && nuxt prepare playground && pnpm prepare:fixtures", - "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics", + "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics", "typecheck": "nuxt typecheck", "release": "pnpm build && bumpp -r --output=CHANGELOG.md", "lint": "eslint .", diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index ab47c404..92c69125 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -54,6 +54,7 @@ export const LOGOS = { dark: ``, }, npm: ``, + calendly: ``, googleRecaptcha: ``, googleSignIn: ``, googleTagManager: ``, diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 8908d4f8..121c16bf 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -116,6 +116,48 @@ "code": "interface ScriptBlueskyEmbedSlots {\n default?: (props: object) => any\n loading?: () => any\n error?: (props: object) => any\n}" } ], + "calendly": [ + { + "name": "CalendlyOptions", + "kind": "const", + "code": "export const CalendlyOptions = object({\n /**\n * The Calendly event URL to embed.\n * Required for inline, popup, and badge widgets when called via the composable.\n * @example 'https://calendly.com/your-name/30min'\n * @see https://help.calendly.com/hc/en-us/articles/223147027\n */\n url: optional(string()),\n /**\n * Pre-fill invitee fields on the booking form.\n * @see https://help.calendly.com/hc/en-us/articles/360020052833\n */\n prefill: optional(object({\n name: optional(string()),\n email: optional(string()),\n firstName: optional(string()),\n lastName: optional(string()),\n /** Custom answers keyed by `a1`, `a2`, ... matching custom question order. */\n customAnswers: optional(record(string(), string())),\n })),\n /**\n * UTM parameters appended to the booking URL for marketing attribution.\n * @see https://help.calendly.com/hc/en-us/articles/360020052833\n */\n utm: optional(object({\n utmCampaign: optional(string()),\n utmSource: optional(string()),\n utmMedium: optional(string()),\n utmContent: optional(string()),\n utmTerm: optional(string()),\n })),\n /**\n * Theme and layout overrides applied to the booking page.\n * @see https://help.calendly.com/hc/en-us/articles/360020052833\n */\n pageSettings: optional(object({\n backgroundColor: optional(string()),\n hideEventTypeDetails: optional(boolean()),\n hideLandingPageDetails: optional(boolean()),\n primaryColor: optional(string()),\n textColor: optional(string()),\n })),\n /**\n * CSS selector for the element that hosts the inline widget.\n * Required when the widget is initialised inline; the element should have a\n * minimum height of around 700px so the booking iframe is fully visible.\n */\n parentElement: optional(string()),\n})" + }, + { + "name": "CalendlyPrefill", + "kind": "interface", + "code": "interface CalendlyPrefill {\n name?: string\n email?: string\n firstName?: string\n lastName?: string\n customAnswers?: Record\n}" + }, + { + "name": "CalendlyUtm", + "kind": "interface", + "code": "interface CalendlyUtm {\n utmCampaign?: string\n utmSource?: string\n utmMedium?: string\n utmContent?: string\n utmTerm?: string\n}" + }, + { + "name": "CalendlyPageSettings", + "kind": "interface", + "code": "interface CalendlyPageSettings {\n backgroundColor?: string\n hideEventTypeDetails?: boolean\n hideLandingPageDetails?: boolean\n primaryColor?: string\n textColor?: string\n}" + }, + { + "name": "CalendlyInlineWidgetOptions", + "kind": "interface", + "code": "export interface CalendlyInlineWidgetOptions {\n url: string\n parentElement: HTMLElement\n prefill?: CalendlyPrefill\n utm?: CalendlyUtm\n pageSettings?: CalendlyPageSettings\n}" + }, + { + "name": "CalendlyPopupWidgetOptions", + "kind": "interface", + "code": "export interface CalendlyPopupWidgetOptions {\n url: string\n rootElement?: HTMLElement\n text?: string\n color?: string\n textColor?: string\n branding?: boolean\n prefill?: CalendlyPrefill\n utm?: CalendlyUtm\n pageSettings?: CalendlyPageSettings\n}" + }, + { + "name": "CalendlyBadgeWidgetOptions", + "kind": "interface", + "code": "export interface CalendlyBadgeWidgetOptions {\n url: string\n text?: string\n color?: string\n textColor?: string\n branding?: boolean\n prefill?: CalendlyPrefill\n utm?: CalendlyUtm\n pageSettings?: CalendlyPageSettings\n}" + }, + { + "name": "CalendlyApi", + "kind": "interface", + "code": "export interface CalendlyApi {\n Calendly: {\n initInlineWidget: (options: CalendlyInlineWidgetOptions) => void\n initPopupWidget: (options: CalendlyPopupWidgetOptions) => void\n initBadgeWidget: (options: CalendlyBadgeWidgetOptions) => void\n showPopupWidget: (url: string) => void\n closePopupWidget: () => void\n initPopupWidgetWithText: (options: CalendlyPopupWidgetOptions) => void\n q?: unknown[]\n }\n}" + } + ], "clarity": [ { "name": "ClarityOptions", @@ -2138,6 +2180,38 @@ "description": "Override the language displayed by the CMP UI (BCP-47 code, e.g. `'en'`, `'de'`)." } ], + "CalendlyOptions": [ + { + "name": "url", + "type": "string", + "required": false, + "description": "The Calendly event URL to embed. Required for inline, popup, and badge widgets when called via the composable." + }, + { + "name": "prefill", + "type": "object", + "required": false, + "description": "Pre-fill invitee fields on the booking form." + }, + { + "name": "utm", + "type": "object", + "required": false, + "description": "UTM parameters appended to the booking URL for marketing attribution." + }, + { + "name": "pageSettings", + "type": "object", + "required": false, + "description": "Theme and layout overrides applied to the booking page." + }, + { + "name": "parentElement", + "type": "string", + "required": false, + "description": "CSS selector for the element that hosts the inline widget. Required when the widget is initialised inline; the element should have a minimum height of around 700px so the booking iframe is fully visible." + } + ], "SegmentOptions": [ { "name": "writeKey", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 2400191f..fc9b22c9 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -16,6 +16,7 @@ import { AhrefsAnalyticsOptions, BingUetOptions, BlueskyEmbedOptions, + CalendlyOptions, ClarityOptions, CloudflareWebAnalyticsOptions, CrispOptions, @@ -165,6 +166,7 @@ export const registryMeta: RegistryScriptMeta[] = [ // cdn m('npm', 'NPM', 'cdn', 'useScriptNpm', { bundle: true }, null), // utility + m('calendly', 'Calendly', 'utility', 'useScriptCalendly', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY), m('googleRecaptcha', 'Google reCAPTCHA', 'utility', 'useScriptGoogleRecaptcha', {}, null), m('googleSignIn', 'Google Sign-In', 'utility', 'useScriptGoogleSignIn', {}, null), m('gravatar', 'Gravatar', 'utility', 'useScriptGravatar', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), @@ -730,6 +732,20 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, }), // utility + def('calendly', { + schema: CalendlyOptions, + label: 'Calendly', + src: 'https://assets.calendly.com/assets/external/widget.js', + category: 'utility', + bundle: true, + proxy: { + // Booking iframes load from calendly.com directly (vendor-hosted) and + // are intentionally not proxied. Only the widget script + assets are. + domains: ['assets.calendly.com'], + privacy: PRIVACY_IP_ONLY, + }, + partytown: { forwards: ['Calendly.initInlineWidget', 'Calendly.initPopupWidget', 'Calendly.initBadgeWidget'] }, + }), def('googleRecaptcha', { schema: GoogleRecaptchaOptions, label: 'Google reCAPTCHA', diff --git a/packages/script/src/runtime/components/ScriptCalendlyInlineWidget.vue b/packages/script/src/runtime/components/ScriptCalendlyInlineWidget.vue new file mode 100644 index 00000000..e543682f --- /dev/null +++ b/packages/script/src/runtime/components/ScriptCalendlyInlineWidget.vue @@ -0,0 +1,123 @@ + + + diff --git a/packages/script/src/runtime/registry/calendly.ts b/packages/script/src/runtime/registry/calendly.ts new file mode 100644 index 00000000..c38e59f0 --- /dev/null +++ b/packages/script/src/runtime/registry/calendly.ts @@ -0,0 +1,157 @@ +import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' +import { useHead } from '@unhead/vue' +import { useRegistryScript } from '../utils' +import { CalendlyOptions } from './schemas' + +export { CalendlyOptions } + +export type CalendlyInput = RegistryScriptInput + +interface CalendlyPrefill { + name?: string + email?: string + firstName?: string + lastName?: string + customAnswers?: Record +} + +interface CalendlyUtm { + utmCampaign?: string + utmSource?: string + utmMedium?: string + utmContent?: string + utmTerm?: string +} + +interface CalendlyPageSettings { + backgroundColor?: string + hideEventTypeDetails?: boolean + hideLandingPageDetails?: boolean + primaryColor?: string + textColor?: string +} + +export interface CalendlyInlineWidgetOptions { + url: string + parentElement: HTMLElement + prefill?: CalendlyPrefill + utm?: CalendlyUtm + pageSettings?: CalendlyPageSettings +} + +export interface CalendlyPopupWidgetOptions { + url: string + rootElement?: HTMLElement + text?: string + color?: string + textColor?: string + branding?: boolean + prefill?: CalendlyPrefill + utm?: CalendlyUtm + pageSettings?: CalendlyPageSettings +} + +export interface CalendlyBadgeWidgetOptions { + url: string + text?: string + color?: string + textColor?: string + branding?: boolean + prefill?: CalendlyPrefill + utm?: CalendlyUtm + pageSettings?: CalendlyPageSettings +} + +export interface CalendlyApi { + Calendly: { + initInlineWidget: (options: CalendlyInlineWidgetOptions) => void + initPopupWidget: (options: CalendlyPopupWidgetOptions) => void + initBadgeWidget: (options: CalendlyBadgeWidgetOptions) => void + showPopupWidget: (url: string) => void + closePopupWidget: () => void + initPopupWidgetWithText: (options: CalendlyPopupWidgetOptions) => void + q?: unknown[] + } +} + +declare global { + interface Window extends CalendlyApi {} +} + +const CALENDLY_CSS_KEY = 'nuxt-scripts-calendly-css' + +const CALENDLY_CLOSE_ICON = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( + '', +)}` + +const CALENDLY_CSS = `.calendly-badge-widget,.calendly-badge-widget *,.calendly-inline-widget,.calendly-inline-widget *,.calendly-overlay,.calendly-overlay *{font-size:16px;line-height:1.2em}.calendly-badge-widget iframe,.calendly-inline-widget iframe,.calendly-overlay iframe{display:inline;height:100%;width:100%}.calendly-popup-content{position:relative}.calendly-popup-content.calendly-mobile{-webkit-overflow-scrolling:touch;overflow-y:auto}.calendly-overlay{background-color:#a5a5a5;background-color:rgba(31,31,31,.4);bottom:0;left:0;overflow:hidden;position:fixed;right:0;top:0;z-index:9999}.calendly-overlay .calendly-close-overlay{bottom:0;left:0;position:absolute;right:0;top:0}.calendly-overlay .calendly-popup{box-sizing:border-box;height:90%;left:50%;max-height:700px!important;max-width:1000px;min-width:900px;position:absolute;top:50%;transform:translateY(-50%) translateX(-50%);width:80%}@media (max-width:975px){.calendly-overlay .calendly-popup{bottom:0;height:auto;left:0;max-height:none;min-width:0;position:fixed;right:0;top:50px;transform:none;width:100%}}.calendly-overlay .calendly-popup .calendly-popup-content{height:100%}.calendly-overlay .calendly-popup-close{background:url(${CALENDLY_CLOSE_ICON}) no-repeat;background-size:contain;color:#fff;cursor:pointer;height:19px;position:absolute;right:25px;top:25px;width:19px}@media (max-width:975px){.calendly-overlay .calendly-popup-close{right:15px;top:15px}}.calendly-badge-widget{bottom:15px;position:fixed;right:20px;z-index:9998}.calendly-badge-widget .calendly-badge-content{border-radius:25px;box-shadow:0 2px 5px rgba(0,0,0,.25);color:#fff;cursor:pointer;display:table-cell;font-family:sans-serif;font-size:14px;font-weight:700;height:45px;padding:0 30px;text-align:center;vertical-align:middle;width:auto}.calendly-badge-widget .calendly-badge-content.calendly-white{color:#666a73}.calendly-badge-widget .calendly-badge-content span{display:block;font-size:12px}.calendly-spinner{left:0;position:absolute;right:0;text-align:center;top:50%;transform:translateY(-50%);z-index:-1}.calendly-spinner>div{animation:calendly-bouncedelay 1.4s ease-in-out infinite;animation-fill-mode:both;background-color:#e1e1e1;border-radius:50%;display:inline-block;height:18px;vertical-align:middle;width:18px}.calendly-spinner .calendly-bounce1{animation-delay:-.32s}.calendly-spinner .calendly-bounce2{animation-delay:-.16s}@keyframes calendly-bouncedelay{0%,80%,to{transform:scale(0)}40%{transform:scale(1)}}` + +let cssInjected = false + +function ensureCalendlyStylesheet() { + if (import.meta.server || cssInjected) + return + cssInjected = true + useHead({ + style: [ + { + key: CALENDLY_CSS_KEY, + innerHTML: CALENDLY_CSS, + }, + ], + }) +} + +/** + * Load the Calendly widget script and expose a typed `Calendly` proxy for + * inline, popup, and badge bookings. + * + * @see https://help.calendly.com/hc/en-us/articles/223147027 + */ +export function useScriptCalendly( + _options?: CalendlyInput, +): UseScriptContext { + ensureCalendlyStylesheet() + + return useRegistryScript('calendly', () => ({ + scriptInput: { + src: 'https://assets.calendly.com/assets/external/widget.js', + crossorigin: false, + }, + schema: import.meta.dev ? CalendlyOptions : undefined, + scriptOptions: { + use() { + return { Calendly: window.Calendly } + }, + }, + clientInit: import.meta.server + ? undefined + : () => { + if (window.Calendly) + return + const queue: unknown[] = [] + const stub = { + q: queue, + initInlineWidget(...args: unknown[]) { + queue.push(['initInlineWidget', ...args]) + }, + initPopupWidget(...args: unknown[]) { + queue.push(['initPopupWidget', ...args]) + }, + initBadgeWidget(...args: unknown[]) { + queue.push(['initBadgeWidget', ...args]) + }, + initPopupWidgetWithText(...args: unknown[]) { + queue.push(['initPopupWidgetWithText', ...args]) + }, + showPopupWidget(...args: unknown[]) { + queue.push(['showPopupWidget', ...args]) + }, + closePopupWidget(...args: unknown[]) { + queue.push(['closePopupWidget', ...args]) + }, + } as unknown as CalendlyApi['Calendly'] + window.Calendly = stub + }, + }), _options) +} diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index bb84f12b..65378d7e 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -890,6 +890,56 @@ export const UsercentricsOptions = object({ language: optional(string()), }) +export const CalendlyOptions = object({ + /** + * The Calendly event URL to embed. + * Required for inline, popup, and badge widgets when called via the composable. + * @example 'https://calendly.com/your-name/30min' + * @see https://help.calendly.com/hc/en-us/articles/223147027 + */ + url: optional(string()), + /** + * Pre-fill invitee fields on the booking form. + * @see https://help.calendly.com/hc/en-us/articles/360020052833 + */ + prefill: optional(object({ + name: optional(string()), + email: optional(string()), + firstName: optional(string()), + lastName: optional(string()), + /** Custom answers keyed by `a1`, `a2`, ... matching custom question order. */ + customAnswers: optional(record(string(), string())), + })), + /** + * UTM parameters appended to the booking URL for marketing attribution. + * @see https://help.calendly.com/hc/en-us/articles/360020052833 + */ + utm: optional(object({ + utmCampaign: optional(string()), + utmSource: optional(string()), + utmMedium: optional(string()), + utmContent: optional(string()), + utmTerm: optional(string()), + })), + /** + * Theme and layout overrides applied to the booking page. + * @see https://help.calendly.com/hc/en-us/articles/360020052833 + */ + pageSettings: optional(object({ + backgroundColor: optional(string()), + hideEventTypeDetails: optional(boolean()), + hideLandingPageDetails: optional(boolean()), + primaryColor: optional(string()), + textColor: optional(string()), + })), + /** + * CSS selector for the element that hosts the inline widget. + * Required when the widget is initialised inline; the element should have a + * minimum height of around 700px so the booking iframe is fully visible. + */ + parentElement: optional(string()), +}) + export const SegmentOptions = object({ /** * Your Segment write key. diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index f8575584..147e750f 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -8,6 +8,7 @@ import type { ComputedRef, Ref } from 'vue' import type { AhrefsAnalyticsInput } from './registry/ahrefs-analytics' import type { BingUetInput } from './registry/bing-uet' import type { BlueskyEmbedInput } from './registry/bluesky-embed' +import type { CalendlyInput } from './registry/calendly' import type { ClarityInput } from './registry/clarity' import type { CloudflareWebAnalyticsInput } from './registry/cloudflare-web-analytics' import type { CrispInput } from './registry/crisp' @@ -230,6 +231,7 @@ export interface ScriptRegistry { bingUet?: BingUetInput blueskyEmbed?: BlueskyEmbedInput carbonAds?: true + calendly?: CalendlyInput crisp?: CrispInput clarity?: ClarityInput cloudflareWebAnalytics?: CloudflareWebAnalyticsInput @@ -275,7 +277,7 @@ export interface ScriptRegistry { * Use this to type-check records that must enumerate all built-in scripts (logos, meta, etc.). */ export type BuiltInRegistryScriptKey - = | 'ahrefsAnalytics' | 'bingUet' | 'blueskyEmbed' | 'carbonAds' | 'crisp' | 'clarity' | 'cloudflareWebAnalytics' + = | 'ahrefsAnalytics' | 'bingUet' | 'blueskyEmbed' | 'calendly' | 'carbonAds' | 'crisp' | 'clarity' | 'cloudflareWebAnalytics' | 'databuddyAnalytics' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index 937b9a88..d0cb7445 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -185,6 +185,10 @@ export const scriptMeta = { }, // Utility + calendly: { + urls: ['https://assets.calendly.com/assets/external/widget.js'], + trackedData: [], + }, googleRecaptcha: { urls: ['https://www.google.com/recaptcha/api.js'], trackedData: [], diff --git a/playground/pages/index.vue b/playground/pages/index.vue index 4bce9239..1cdcd0cd 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -46,6 +46,7 @@ function getPlaygroundPath(script: any): string | null { 'youtube-player': '/third-parties/youtube/nuxt-scripts', 'google-maps': '/third-parties/google-maps/nuxt-scripts', 'google-recaptcha': '/third-parties/google-recaptcha/nuxt-scripts', + 'calendly': '/third-parties/calendly/nuxt-scripts', 'usercentrics': '/third-parties/usercentrics/nuxt-scripts', 'npm': '/npm/js-confetti', } @@ -274,6 +275,10 @@ const benchmark = [ name: 'LinkedIn Insight (Default)', path: '/third-parties/linkedin-insight/default', }, + { + name: 'Calendly (Default)', + path: '/third-parties/calendly/default', + }, { name: 'Ahrefs Analytics (Default)', path: '/third-parties/ahrefs-analytics/default', diff --git a/playground/pages/third-parties/calendly/default.vue b/playground/pages/third-parties/calendly/default.vue new file mode 100644 index 00000000..3414cd60 --- /dev/null +++ b/playground/pages/third-parties/calendly/default.vue @@ -0,0 +1,30 @@ + + + diff --git a/playground/pages/third-parties/calendly/nuxt-scripts.vue b/playground/pages/third-parties/calendly/nuxt-scripts.vue new file mode 100644 index 00000000..dfa87e0b --- /dev/null +++ b/playground/pages/third-parties/calendly/nuxt-scripts.vue @@ -0,0 +1,71 @@ + + + diff --git a/test/e2e/_calendly-suite.ts b/test/e2e/_calendly-suite.ts new file mode 100644 index 00000000..042d6324 --- /dev/null +++ b/test/e2e/_calendly-suite.ts @@ -0,0 +1,147 @@ +import { getBrowser, url } from '@nuxt/test-utils/e2e' +import { expect, it } from 'vitest' + +interface SuiteOptions { + bundled: boolean +} + +async function newCapturePage(_opts: SuiteOptions) { + const browser = await getBrowser() + const page = await browser.newPage() + // The bundled and CDN-served widget.js is identical in shape; both run + // for real in their respective fixtures. We don't stub them — we want to + // assert the *real* artefact mounts an iframe in the requested + // parentElement, which is the integration contract that matters. The + // iframe's booking page itself loads from calendly.com and is blocked + // here so the test doesn't depend on vendor uptime / per-CI egress. + await page.route(/calendly\.com\/example\//, async (route) => { + await route.fulfill({ status: 200, contentType: 'text/html', body: 'stubbed booking' }) + }) + return page +} + +async function waitFor( + predicate: () => Promise | boolean, + { timeoutMs = 10000, intervalMs = 50, message = 'condition' }: { timeoutMs?: number, intervalMs?: number, message?: string } = {}, +) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await predicate()) + return + await new Promise(r => setTimeout(r, intervalMs)) + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for ${message}`) +} + +export function defineCalendlySuite(opts: SuiteOptions) { + it('script tag points at the expected origin', async () => { + const page = await newCapturePage(opts) + try { + await page.goto(url('/calendly'), { waitUntil: 'domcontentloaded', timeout: 30000 }) + const scriptSelector = opts.bundled + ? 'script[src*="/_scripts/assets/"]' + : 'script[src*="assets.calendly.com/assets/external/widget.js"]' + await page.waitForSelector(scriptSelector, { state: 'attached', timeout: 15000 }) + } + finally { + await page.close() + } + }, 60000) + + it('injects the Calendly widget stylesheet inline (no leak to assets.calendly.com)', async () => { + // Privacy contract: PRIVACY_IP_ONLY claims no IP leaks to the vendor on + // page load. The stylesheet used to load from + // https://assets.calendly.com/assets/external/widget.css, which leaked + // the user's IP every page-render — and the close-icon SVG referenced + // inside it leaked again on every popup close. The composable now + // injects the stylesheet inline (with the SVG as a data URI), so this + // suite asserts both halves of that contract: the inline