From b1b4f83e74a2d0dfaf0a5e2bd77c5e8dbf7f9012 Mon Sep 17 00:00:00 2001 From: Karakatiza666 Date: Fri, 7 Aug 2026 06:01:41 +0000 Subject: [PATCH 1/2] [web-console] Pass ConceptualHQ visitor ID and CTA placement to Calendly to help track user journey in the public sandbox Signed-off-by: Karakatiza666 --- .../src/lib/components/layout/Footer.svelte | 2 +- .../pipelines/PipelineEditLayout.svelte | 2 +- .../src/lib/components/other/BookADemo.svelte | 18 ++- .../components/other/BookADemo.svelte.spec.ts | 104 ++++++++++++++++++ .../components/pipelines/list/Actions.svelte | 15 ++- .../compositions/pipelines/useTryPipeline.ts | 4 +- .../useConceptualHq.svelte.spec.ts | 86 +++++++++++++++ .../compositions/useConceptualHq.svelte.ts | 41 +++++++ .../src/lib/services/calendly.spec.ts | 40 +++++++ .../web-console/src/lib/services/calendly.ts | 51 +++++++++ .../src/lib/services/conceptualHq.spec.ts | 32 ++++++ .../src/lib/services/conceptualHq.ts | 3 + .../(system)/(authenticated)/+layout.svelte | 2 +- .../(system)/(authenticated)/+page.svelte | 3 +- .../(authenticated)/demos/+page.svelte | 4 +- .../(authenticated)/health/+page.svelte | 4 +- 16 files changed, 389 insertions(+), 22 deletions(-) create mode 100644 js-packages/web-console/src/lib/components/other/BookADemo.svelte.spec.ts create mode 100644 js-packages/web-console/src/lib/compositions/useConceptualHq.svelte.spec.ts create mode 100644 js-packages/web-console/src/lib/compositions/useConceptualHq.svelte.ts create mode 100644 js-packages/web-console/src/lib/services/calendly.spec.ts create mode 100644 js-packages/web-console/src/lib/services/calendly.ts diff --git a/js-packages/web-console/src/lib/components/layout/Footer.svelte b/js-packages/web-console/src/lib/components/layout/Footer.svelte index 26ad1fe82f2..b80751c9d02 100644 --- a/js-packages/web-console/src/lib/components/layout/Footer.svelte +++ b/js-packages/web-console/src/lib/components/layout/Footer.svelte @@ -15,7 +15,7 @@ {/if}
- + {#snippet icon()}{/snippet} Book a demo diff --git a/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte b/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte index 680b362efd1..edb17e303e9 100644 --- a/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte +++ b/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte @@ -312,7 +312,7 @@
- Book a demo {/if} diff --git a/js-packages/web-console/src/lib/components/other/BookADemo.svelte b/js-packages/web-console/src/lib/components/other/BookADemo.svelte index dd25f124271..c04aada50d3 100644 --- a/js-packages/web-console/src/lib/components/other/BookADemo.svelte +++ b/js-packages/web-console/src/lib/components/other/BookADemo.svelte @@ -1,15 +1,24 @@ {#snippet defaultIcon()} @@ -21,8 +30,7 @@ href={calendlyUrl} target="_blank" rel="noreferrer" - onclick={() => - captureEvent('calendly_opened', { url: calendlyUrl, trigger_location: triggerLocation })} + onclick={() => captureEvent('calendly_opened', { url: calendlyUrl, placement })} > {@render icon()} {@render children?.()} diff --git a/js-packages/web-console/src/lib/components/other/BookADemo.svelte.spec.ts b/js-packages/web-console/src/lib/components/other/BookADemo.svelte.spec.ts new file mode 100644 index 00000000000..35258e4e820 --- /dev/null +++ b/js-packages/web-console/src/lib/components/other/BookADemo.svelte.spec.ts @@ -0,0 +1,104 @@ +// Component tests for the demo link's visitor-ID tag: the Calendly URL must +// carry the analytics visitor ID so Calendly can forward it to Zapier/HubSpot +// with the booking. On a first visit the ID only exists once the analytics +// loader lands, which is after the first render, so the link has to retag +// itself reactively. + +import { createRawSnippet } from 'svelte' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { page } from 'vitest/browser' +import { render } from 'vitest-browser-svelte' +import { refreshConceptualHqDeviceId } from '$lib/compositions/useConceptualHq.svelte' + +const captureEvent = vi.hoisted(() => vi.fn()) + +vi.mock('$lib/services/analytics', () => ({ captureEvent })) + +import BookADemo from './BookADemo.svelte' + +const DEMO_URL = 'https://calendly.com/d/cxz2-37b-qqd/feldera-demo-30min' + +// Stands in for the loader script having installed the full `ca` API. +const loaderReports = (deviceId: string) => { + window.ca = Object.assign(() => {}, { q: [], getDeviceId: () => deviceId }) + refreshConceptualHqDeviceId() +} + +// Link label. The default icon alone is an empty webfont span, which playwright +// treats as invisible and refuses to click. +const label = createRawSnippet(() => ({ render: () => 'Book a demo' })) + +// Keep clicks from opening the real Calendly tab. +const swallowNavigation = () => { + const cancel = (event: Event) => event.preventDefault() + document.addEventListener('click', cancel, { capture: true }) + return () => document.removeEventListener('click', cancel, { capture: true }) +} + +afterEach(() => { + window.ca = undefined + refreshConceptualHqDeviceId() + captureEvent.mockClear() +}) + +describe('BookADemo.svelte', () => { + it('tags the link with the visitor ID known at render', async () => { + loaderReports('dev-123') + render(BookADemo, { children: label }) + + await expect + .element(page.getByRole('link')) + .toHaveAttribute('href', `${DEMO_URL}?utm_content=dev-123`) + }) + + it('tags the link with the placement of the button', async () => { + loaderReports('dev-123') + render(BookADemo, { placement: 'footer', children: label }) + + await expect + .element(page.getByRole('link')) + .toHaveAttribute('href', `${DEMO_URL}?utm_content=dev-123&utm_term=try.feldera.com%3Afooter`) + }) + + it('links to the untagged URL when neither the visitor nor the placement is known', async () => { + render(BookADemo, { children: label }) + + await expect.element(page.getByRole('link')).toHaveAttribute('href', DEMO_URL) + }) + + it('retags the link when the visitor ID arrives after rendering', async () => { + render(BookADemo, { placement: 'footer', children: label }) + const link = page.getByRole('link') + await expect + .element(link) + .toHaveAttribute('href', `${DEMO_URL}?utm_term=try.feldera.com%3Afooter`) + + loaderReports('late-loader') + + // Reading the ID once at render leaves the href unattributed here, and the + // booking would reach HubSpot without a visitor. + await expect + .element(link) + .toHaveAttribute( + 'href', + `${DEMO_URL}?utm_content=late-loader&utm_term=try.feldera.com%3Afooter` + ) + }) + + it('reports the tagged URL and the placement to analytics on click', async () => { + const restore = swallowNavigation() + try { + loaderReports('dev-123') + render(BookADemo, { placement: 'footer', children: label }) + + await page.getByRole('link').click() + + expect(captureEvent).toHaveBeenCalledWith('calendly_opened', { + url: `${DEMO_URL}?utm_content=dev-123&utm_term=try.feldera.com%3Afooter`, + placement: 'footer' + }) + } finally { + restore() + } + }) +}) diff --git a/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte b/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte index 3274b080554..1ffd84ce7cc 100644 --- a/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte @@ -55,6 +55,7 @@ groups related actions into multi-action dropdowns when multiple options are ava usePipelineList, useUpdatePipelineList } from '$lib/compositions/pipelines/usePipelineList.svelte' + import { useConceptualHq } from '$lib/compositions/useConceptualHq.svelte' import { usePermission } from '$lib/compositions/usePermission.svelte' import { getPipelineAction } from '$lib/compositions/usePipelineAction.svelte' import { usePipelineManager } from '$lib/compositions/usePipelineManager.svelte' @@ -68,6 +69,7 @@ groups related actions into multi-action dropdowns when multiple options are ava } from '$lib/functions/pipelines/status' import { resolve } from '$lib/functions/svelte' import { captureEvent } from '$lib/services/analytics' + import { bookADemoUrl } from '$lib/services/calendly' import type { PipelineAction } from '$lib/services/pipelineManager' import type { Snippet } from '$lib/types/svelte' @@ -115,6 +117,12 @@ groups related actions into multi-action dropdowns when multiple options are ava } const { toastError } = useToast() + const conceptualHq = useConceptualHq() + const calendlyPlacement = 'pipeline_actions_upgrade' + const calendlyUrl = $derived( + bookADemoUrl({ visitorId: conceptualHq.deviceId, placement: calendlyPlacement }) + ) + // An already-deleted pipeline offers no Delete action; otherwise the backend // dictates when deletion is possible (fully stopped, storage cleared). const deleteDisabledReason = $derived( @@ -884,13 +892,12 @@ groups related actions into multi-action dropdowns when multiple options are ava Stopping pipelines gracefully is only available in the Enterprise edition.
- captureEvent('calendly_opened', { - url: 'https://calendly.com/d/cqnj-p63-mbq/feldera-demo' - })}>UpgradeUpgrade {/if} diff --git a/js-packages/web-console/src/lib/compositions/pipelines/useTryPipeline.ts b/js-packages/web-console/src/lib/compositions/pipelines/useTryPipeline.ts index 869fde0d80b..099c0375c59 100644 --- a/js-packages/web-console/src/lib/compositions/pipelines/useTryPipeline.ts +++ b/js-packages/web-console/src/lib/compositions/pipelines/useTryPipeline.ts @@ -12,7 +12,7 @@ export const useTryPipeline = () => { const { updatePipelines } = useUpdatePipelineList() const pipelineList = usePipelineList() const api = usePipelineManager() - return async (pipeline: Omit, trigger_location?: string) => { + return async (pipeline: Omit, placement?: string) => { // When the demo's pipeline already exists, just open it. This lets a // read-only caller follow a demo tile without a create call (a 403), and // spares a write caller from clobbering an existing pipeline. @@ -20,7 +20,7 @@ export const useTryPipeline = () => { captureEvent('demo_opened', { demo: pipeline.name, already_created, - trigger_location + placement }) if (!already_created) { try { diff --git a/js-packages/web-console/src/lib/compositions/useConceptualHq.svelte.spec.ts b/js-packages/web-console/src/lib/compositions/useConceptualHq.svelte.spec.ts new file mode 100644 index 00000000000..7a83944526c --- /dev/null +++ b/js-packages/web-console/src/lib/compositions/useConceptualHq.svelte.spec.ts @@ -0,0 +1,86 @@ +// The visitor ID has two sources: `ca.getDeviceId`, which exists only after the +// ConceptualHQ loader replaces its queue stub, and the localStorage key the +// loader writes. Both are read by `refreshConceptualHqDeviceId`, which the +// service calls when the loader lands, and by the module's initial read. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { refreshConceptualHqDeviceId, useConceptualHq } from './useConceptualHq.svelte' + +const DEVICE_ID_STORAGE_KEY = '_ca_device_id' + +const loaderReports = (deviceId: string) => { + window.ca = Object.assign(() => {}, { q: [], getDeviceId: () => deviceId }) +} + +afterEach(() => { + window.ca = undefined + window.localStorage.removeItem(DEVICE_ID_STORAGE_KEY) + vi.restoreAllMocks() + refreshConceptualHqDeviceId() +}) + +describe('useConceptualHq', () => { + it('reads the ID from the loader API', () => { + loaderReports('dev-123') + window.localStorage.setItem(DEVICE_ID_STORAGE_KEY, 'dev-456') + refreshConceptualHqDeviceId() + + expect(useConceptualHq().deviceId).toBe('dev-123') + }) + + it('falls back to the stored ID while only the queue stub exists', () => { + window.localStorage.setItem(DEVICE_ID_STORAGE_KEY, 'dev-456') + refreshConceptualHqDeviceId() + + expect(useConceptualHq().deviceId).toBe('dev-456') + }) + + it('is empty when neither source has an ID', () => { + refreshConceptualHqDeviceId() + + expect(useConceptualHq().deviceId).toBe('') + }) + + it('is empty when reading storage throws', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('site data blocked') + }) + refreshConceptualHqDeviceId() + + expect(useConceptualHq().deviceId).toBe('') + }) +}) + +describe('refreshConceptualHqDeviceId', () => { + it('reaches callers that read the ID before the refresh', () => { + const earlyCaller = useConceptualHq() + expect(earlyCaller.deviceId).toBe('') + + loaderReports('late-loader') + refreshConceptualHqDeviceId() + + // One state serves every caller, so the early one sees the new ID too. + expect(earlyCaller.deviceId).toBe('late-loader') + expect(useConceptualHq().deviceId).toBe('late-loader') + }) + + it('notifies effects tracking the ID', async () => { + const conceptualHq = useConceptualHq() + const seen: string[] = [] + + const stop = $effect.root(() => { + $effect(() => { + seen.push(conceptualHq.deviceId) + }) + }) + await vi.waitFor(() => expect(seen).toEqual([''])) + + loaderReports('late-loader') + refreshConceptualHqDeviceId() + + // A plain (non-state) variable would leave the effect asleep here. + await vi.waitFor(() => expect(seen).toEqual(['', 'late-loader'])) + + stop() + }) +}) diff --git a/js-packages/web-console/src/lib/compositions/useConceptualHq.svelte.ts b/js-packages/web-console/src/lib/compositions/useConceptualHq.svelte.ts new file mode 100644 index 00000000000..d2b1d90460e --- /dev/null +++ b/js-packages/web-console/src/lib/compositions/useConceptualHq.svelte.ts @@ -0,0 +1,41 @@ +// localStorage key where the ConceptualHQ loader persists the visitor (device) ID. +const DEVICE_ID_STORAGE_KEY = '_ca_device_id' + +// Read Device ID from localStorage while the loader has not run yet +// so a returning visitor is identified on the first render. +const readDeviceId = (): string => { + if (typeof window === 'undefined') { + return '' + } + const fromLoader = window.ca?.getDeviceId?.() + if (fromLoader) { + return fromLoader + } + try { + return window.localStorage.getItem(DEVICE_ID_STORAGE_KEY) ?? '' + } catch { + // Reading localStorage throws when the browser blocks site data. + return '' + } +} + +const visitor = $state({ deviceId: readDeviceId() }) + +/** + * Re-read the visitor ID. The ConceptualHQ service calls this when its loader + * script is run, which is when a first-time visitor gets an ID. + */ +export const refreshConceptualHqDeviceId = () => { + visitor.deviceId = readDeviceId() +} + +/** + * `deviceId` is '' while no ID is known: analytics disabled, a first visit + * when the loader hasn't run yet, or localStorage is blocked. + * Callers treat '' as "cannot attribute", not as an error. + */ +export const useConceptualHq = () => ({ + get deviceId() { + return visitor.deviceId + } +}) diff --git a/js-packages/web-console/src/lib/services/calendly.spec.ts b/js-packages/web-console/src/lib/services/calendly.spec.ts new file mode 100644 index 00000000000..a75e25d1bb6 --- /dev/null +++ b/js-packages/web-console/src/lib/services/calendly.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' + +import { bookADemoUrl } from './calendly' + +const DEMO_URL = 'https://calendly.com/d/cxz2-37b-qqd/feldera-demo-30min' + +const paramsOf = (url: string) => Object.fromEntries(new URL(url).searchParams) + +describe('bookADemoUrl', () => { + it('tags the link with the visitor ID and the placement', () => { + expect(paramsOf(bookADemoUrl({ visitorId: 'dev-123', placement: 'footer' }))).toEqual({ + utm_content: 'dev-123', + // The prefix keeps console placements apart from the website's. + utm_term: 'try.feldera.com:footer' + }) + }) + + it('carries the placement even when the visitor is unknown', () => { + // The booking cannot be tied to a visitor, but sales still sees its source. + expect(bookADemoUrl({ visitorId: '', placement: 'footer' })).toBe( + `${DEMO_URL}?utm_term=try.feldera.com%3Afooter` + ) + }) + + it('carries the visitor ID when no placement is given', () => { + // No placement, no prefix: a bare prefix would name no button at all. + expect(bookADemoUrl({ visitorId: 'dev-123' })).toBe(`${DEMO_URL}?utm_content=dev-123`) + }) + + it('percent-encodes values holding URL-significant characters', () => { + const url = bookADemoUrl({ visitorId: 'a&b=c d', placement: 'x&y' }) + // A raw '&' or '=' would split into bogus extra parameters. + expect(url).toBe(`${DEMO_URL}?utm_content=a%26b%3Dc+d&utm_term=try.feldera.com%3Ax%26y`) + expect(paramsOf(url)).toEqual({ utm_content: 'a&b=c d', utm_term: 'try.feldera.com:x&y' }) + }) + + it('leaves the link untagged when nothing is known', () => { + expect(bookADemoUrl({ visitorId: '' })).toBe(DEMO_URL) + }) +}) diff --git a/js-packages/web-console/src/lib/services/calendly.ts b/js-packages/web-console/src/lib/services/calendly.ts new file mode 100644 index 00000000000..bb45a6c7a84 --- /dev/null +++ b/js-packages/web-console/src/lib/services/calendly.ts @@ -0,0 +1,51 @@ +/** Feldera's "book a demo" Calendly event. */ +const DEMO_URL = 'https://calendly.com/d/cxz2-37b-qqd/feldera-demo-30min' + +/** + * Calendly stores its query parameters with the booking and forwards them to + * everything downstream (Zapier, then HubSpot), so the analytics visitor ID + * travels through them and ties the booking back to the visitor's + * earlier activity. + * + * If the original link ever includes 'utm_content' - carry `visitorId` in 'salesforce_uuid' + */ +const VISITOR_ID_PARAM = 'utm_content' + +/** + * Which button the visitor booked from, e.g. 'try.feldera.com:pipeline_editor'. + * Rides the same route as the visitor ID, so sales sees the page that earned + * the booking. The part after the prefix matches the `placement` property of + * our analytics events. + */ +const PLACEMENT_PARAM = 'utm_term' + +// Every property that books demos feeds the same Calendly event, so the web-console +// namespaces its placements to distinguish from the website's. +const PLACEMENT_PREFIX = 'try.feldera.com:' + +/** + * The demo link, tagged with the analytics visitor ID and the placement of the + * button that opens it. An empty ID leaves the link unattributed: the booking + * still works, it just does not tie back to the visitor. + * Needs to be used within $derived to update the URL + * when the conceptualHq.deviceId becomes available + */ +export const bookADemoUrl = ({ + visitorId, + placement +}: { + visitorId: string + placement?: string +}): string => { + if (!visitorId && !placement) { + return DEMO_URL + } + const url = new URL(DEMO_URL) + if (visitorId) { + url.searchParams.set(VISITOR_ID_PARAM, visitorId) + } + if (placement) { + url.searchParams.set(PLACEMENT_PARAM, PLACEMENT_PREFIX + placement) + } + return url.toString() +} diff --git a/js-packages/web-console/src/lib/services/conceptualHq.spec.ts b/js-packages/web-console/src/lib/services/conceptualHq.spec.ts index 2ad785ebf9f..4078bc42e12 100644 --- a/js-packages/web-console/src/lib/services/conceptualHq.spec.ts +++ b/js-packages/web-console/src/lib/services/conceptualHq.spec.ts @@ -3,6 +3,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Configuration } from '$lib/services/manager' import type { UserProfile } from '$lib/types/auth' +// The composition owns the reactive visitor ID; here we only assert that the +// loader script triggers a refresh. Mocking it also keeps runes out of this +// node-environment spec. +const refreshConceptualHqDeviceId = vi.hoisted(() => vi.fn()) + +vi.mock('$lib/compositions/useConceptualHq.svelte', () => ({ refreshConceptualHqDeviceId })) + const profile: UserProfile = { id: 'user-1', email: 'a@b.com', name: 'Ann' } // Only `conceptualhq` is read; the rest is filler to satisfy the type. @@ -35,6 +42,7 @@ const queued = () => (window.ca?.q ?? []).map((a) => Array.from(a)) afterEach(() => { vi.unstubAllGlobals() + refreshConceptualHqDeviceId.mockClear() }) describe('initConceptualHq', () => { @@ -88,6 +96,30 @@ describe('initConceptualHq', () => { }) }) +describe('visitor ID refresh', () => { + it('refreshes the visitor ID when the loader script arrives', async () => { + const { script } = stubDom() + const { initConceptualHq } = await freshModule(true) + + initConceptualHq(config('my-key'), profile) + expect(refreshConceptualHqDeviceId).not.toHaveBeenCalled() + ;(script.onload as () => void)() + + // The ID only exists once the loader has replaced the queue stub, which is + // after the first render on a first visit. + expect(refreshConceptualHqDeviceId).toHaveBeenCalledOnce() + }) + + it('never refreshes when analytics is disabled', async () => { + stubDom() + const { initConceptualHq } = await freshModule(true) + + initConceptualHq(config(''), profile) + + expect(refreshConceptualHqDeviceId).not.toHaveBeenCalled() + }) +}) + describe('trackConceptualHq', () => { it('enqueues a track call with properties once the loader is installed', async () => { stubDom() diff --git a/js-packages/web-console/src/lib/services/conceptualHq.ts b/js-packages/web-console/src/lib/services/conceptualHq.ts index 39bd9366aca..7e6677ac792 100644 --- a/js-packages/web-console/src/lib/services/conceptualHq.ts +++ b/js-packages/web-console/src/lib/services/conceptualHq.ts @@ -1,3 +1,4 @@ +import { refreshConceptualHqDeviceId } from '$lib/compositions/useConceptualHq.svelte' import type { Configuration } from '$lib/services/manager' import type { UserProfile } from '$lib/types/auth' @@ -11,6 +12,7 @@ import type { UserProfile } from '$lib/types/auth' type ConceptualAnalytics = ((command: string, ...args: unknown[]) => void) & { q: unknown[][] l?: number + getDeviceId?: () => string | null | undefined } declare global { @@ -47,6 +49,7 @@ const loadConceptualAnalytics = (key: string): ConceptualAnalytics => { const script = document.createElement('script') script.async = true script.src = `${LOADER_BASE}?key=${encodeURIComponent(key)}&v=${LOADER_VERSION}` + script.onload = refreshConceptualHqDeviceId const firstScript = document.getElementsByTagName('script')[0] firstScript.parentNode?.insertBefore(script, firstScript) diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/+layout.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/+layout.svelte index 916e101db62..29bb31fbb28 100644 --- a/js-packages/web-console/src/routes/(system)/(authenticated)/+layout.svelte +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/+layout.svelte @@ -226,7 +226,7 @@ }} > - Book a demo diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/+page.svelte index 0199fd6d639..2af366b5620 100644 --- a/js-packages/web-console/src/routes/(system)/(authenticated)/+page.svelte +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/+page.svelte @@ -74,8 +74,7 @@ - Book a demo + Book a demo {/if} {/snippet} diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/demos/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/demos/+page.svelte index c8120f2e5e3..1c4093043ae 100644 --- a/js-packages/web-console/src/routes/(system)/(authenticated)/demos/+page.svelte +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/demos/+page.svelte @@ -56,9 +56,7 @@ - Book a demo + Book a demo {/if} {/snippet} diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/health/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/health/+page.svelte index a49822313de..e8609bba82a 100644 --- a/js-packages/web-console/src/routes/(system)/(authenticated)/health/+page.svelte +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/health/+page.svelte @@ -258,9 +258,7 @@ - Book a demo + Book a demo {/if} {/snippet} From 46b8f1dfb59030ca7d439d7c217073856bb92956 Mon Sep 17 00:00:00 2001 From: Karakatiza666 Date: Fri, 7 Aug 2026 08:30:38 +0000 Subject: [PATCH 2/2] [web-console] Calendly tracking PR review fixes Signed-off-by: Karakatiza666 --- .../src/lib/components/other/DemoTile.svelte | 4 ++-- .../components/other/DemoTile.svelte.spec.ts | 15 ++++++++++++++- .../src/lib/services/calendly.spec.ts | 18 +++++++++++++++++- .../web-console/src/lib/services/calendly.ts | 18 ++++++++++++++++-- .../src/lib/services/conceptualHq.ts | 6 ++++++ .../(system)/(authenticated)/+page.svelte | 2 +- .../(authenticated)/demos/+page.svelte | 2 +- 7 files changed, 57 insertions(+), 8 deletions(-) diff --git a/js-packages/web-console/src/lib/components/other/DemoTile.svelte b/js-packages/web-console/src/lib/components/other/DemoTile.svelte index db276cf0c57..6460789e28f 100644 --- a/js-packages/web-console/src/lib/components/other/DemoTile.svelte +++ b/js-packages/web-console/src/lib/components/other/DemoTile.svelte @@ -4,7 +4,7 @@ import { usePermission } from '$lib/compositions/usePermission.svelte' import type { Demo } from '$lib/services/pipelineManager' - let { demo, triggerLocation }: { demo: Demo | null; triggerLocation?: string } = $props() + let { demo, placement }: { demo: Demo | null; placement?: string } = $props() const tryPipeline = useTryPipeline() const canCreate = usePermission('write:pipeline') const list = usePipelineList() @@ -21,7 +21,7 @@ class="text-left disabled:pointer-events-none disabled:opacity-50" disabled={!enabled} title={enabled ? undefined : 'Creating a demo pipeline needs write access'} - onclick={() => tryPipeline(demo, triggerLocation)} + onclick={() => tryPipeline(demo, placement)} > {demo.title} diff --git a/js-packages/web-console/src/lib/components/other/DemoTile.svelte.spec.ts b/js-packages/web-console/src/lib/components/other/DemoTile.svelte.spec.ts index 9fdfe36f998..59afe5b900f 100644 --- a/js-packages/web-console/src/lib/components/other/DemoTile.svelte.spec.ts +++ b/js-packages/web-console/src/lib/components/other/DemoTile.svelte.spec.ts @@ -29,8 +29,10 @@ vi.mock('$lib/compositions/pipelines/usePipelineList.svelte', () => ({ }) })) +const tryPipeline = vi.hoisted(() => vi.fn()) + vi.mock('$lib/compositions/pipelines/useTryPipeline', () => ({ - useTryPipeline: () => vi.fn() + useTryPipeline: () => tryPipeline })) import DemoTile from './DemoTile.svelte' @@ -45,6 +47,7 @@ const demo = { afterEach(() => { roleState.current = 'read' listState.current = [] + tryPipeline.mockClear() }) describe('DemoTile.svelte', () => { @@ -69,4 +72,14 @@ describe('DemoTile.svelte', () => { await render(DemoTile, { demo }) await expect.element(page.getByRole('button', { name: 'Demo One' })).not.toBeDisabled() }) + + it('reports where the demo was opened from', async () => { + roleState.current = 'write' + await render(DemoTile, { demo, placement: 'home' }) + + await page.getByRole('button', { name: 'Demo One' }).click() + + // The placement reaches the `demo_opened` analytics event through here. + expect(tryPipeline).toHaveBeenCalledWith(demo, 'home') + }) }) diff --git a/js-packages/web-console/src/lib/services/calendly.spec.ts b/js-packages/web-console/src/lib/services/calendly.spec.ts index a75e25d1bb6..45ba5718c51 100644 --- a/js-packages/web-console/src/lib/services/calendly.spec.ts +++ b/js-packages/web-console/src/lib/services/calendly.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { bookADemoUrl } from './calendly' @@ -6,6 +6,10 @@ const DEMO_URL = 'https://calendly.com/d/cxz2-37b-qqd/feldera-demo-30min' const paramsOf = (url: string) => Object.fromEntries(new URL(url).searchParams) +afterEach(() => { + vi.restoreAllMocks() +}) + describe('bookADemoUrl', () => { it('tags the link with the visitor ID and the placement', () => { expect(paramsOf(bookADemoUrl({ visitorId: 'dev-123', placement: 'footer' }))).toEqual({ @@ -37,4 +41,16 @@ describe('bookADemoUrl', () => { it('leaves the link untagged when nothing is known', () => { expect(bookADemoUrl({ visitorId: '' })).toBe(DEMO_URL) }) + + it('writes both tags into a link that carries no parameters of its own', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + bookADemoUrl({ visitorId: 'dev-123', placement: 'footer' }) + + // The day the demo link itself carries `utm_content` or `utm_term`, this + // warns instead of silently replacing the value, and the ID must move to a + // free parameter such as `salesforce_uuid`. + expect(warn).not.toHaveBeenCalled() + expect([...new URL(bookADemoUrl({ visitorId: '' })).searchParams]).toEqual([]) + }) }) diff --git a/js-packages/web-console/src/lib/services/calendly.ts b/js-packages/web-console/src/lib/services/calendly.ts index bb45a6c7a84..a75b68066cd 100644 --- a/js-packages/web-console/src/lib/services/calendly.ts +++ b/js-packages/web-console/src/lib/services/calendly.ts @@ -23,6 +23,20 @@ const PLACEMENT_PARAM = 'utm_term' // namespaces its placements to distinguish from the website's. const PLACEMENT_PREFIX = 'try.feldera.com:' +/** + * Tag the link, reporting collisions with any existing query params. + */ +const setTrackingParam = (url: URL, param: string, value: string) => { + if (url.searchParams.has(param)) { + console.warn( + `Calendly demo link already carries '${param}' ` + + `('${url.searchParams.get(param)}'); overwriting it with '${value}'. ` + + `Move our value to a free parameter, e.g. 'salesforce_uuid', and remap it in Zapier.` + ) + } + url.searchParams.set(param, value) +} + /** * The demo link, tagged with the analytics visitor ID and the placement of the * button that opens it. An empty ID leaves the link unattributed: the booking @@ -42,10 +56,10 @@ export const bookADemoUrl = ({ } const url = new URL(DEMO_URL) if (visitorId) { - url.searchParams.set(VISITOR_ID_PARAM, visitorId) + setTrackingParam(url, VISITOR_ID_PARAM, visitorId) } if (placement) { - url.searchParams.set(PLACEMENT_PARAM, PLACEMENT_PREFIX + placement) + setTrackingParam(url, PLACEMENT_PARAM, PLACEMENT_PREFIX + placement) } return url.toString() } diff --git a/js-packages/web-console/src/lib/services/conceptualHq.ts b/js-packages/web-console/src/lib/services/conceptualHq.ts index 7e6677ac792..d6cfb99e8f8 100644 --- a/js-packages/web-console/src/lib/services/conceptualHq.ts +++ b/js-packages/web-console/src/lib/services/conceptualHq.ts @@ -49,6 +49,12 @@ const loadConceptualAnalytics = (key: string): ConceptualAnalytics => { const script = document.createElement('script') script.async = true script.src = `${LOADER_BASE}?key=${encodeURIComponent(key)}&v=${LOADER_VERSION}` + // The loader installs the full `ca` API, and with it the visitor ID. It does + // so before `onload` fires: replacing the stub and draining + // `ca.q` are synchronous steps of the script, so `getDeviceId` is in place by + // the time we read it. + // Potential for regression: were the loaded script to defer that work, a first-time + // visitor would keep the empty ID until the next page load. script.onload = refreshConceptualHqDeviceId const firstScript = document.getElementsByTagName('script')[0] firstScript.parentNode?.insertBefore(script, firstScript) diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/+page.svelte index 2af366b5620..dc9f47e349b 100644 --- a/js-packages/web-console/src/routes/(system)/(authenticated)/+page.svelte +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/+page.svelte @@ -204,7 +204,7 @@ class="grid grid-cols-1 gap-x-6 gap-y-5 py-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-5" > {#each demos.current.slice(0, maxShownDemos) as demo} - + {/each}
diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/demos/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/demos/+page.svelte index 1c4093043ae..4fbe7d58d05 100644 --- a/js-packages/web-console/src/routes/(system)/(authenticated)/demos/+page.svelte +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/demos/+page.svelte @@ -70,7 +70,7 @@ {#each demos.current.filter((demo) => { return !demo || demosType === 'All' || demo.type === demosType }) as demo} - + {/each}