Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<FelderaLogoBlackDetail class="h-8 pl-4 opacity-40"></FelderaLogoBlackDetail>
{/if}
<div class="flex flex-col gap-1 md:flex-row">
<BookADemo class="btn justify-start px-4 hover:bg-surface-50-950" triggerLocation="footer">
<BookADemo class="btn justify-start px-4 hover:bg-surface-50-950" placement="footer">
<!-- svelte-ignore block_empty -->
{#snippet icon()}{/snippet}
Book a demo
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@
<CreatePipelineButton inputClass="max-w-64" btnClass="preset-filled-surface-50-950"
></CreatePipelineButton>
</div>
<BookADemo class="btn-icon preset-filled-surface-50-950" triggerLocation="pipeline_editor"
<BookADemo class="btn-icon preset-filled-surface-50-950" placement="pipeline_editor"
></BookADemo>
<Tooltip class="">Book a demo</Tooltip>
{/if}
Expand Down
18 changes: 13 additions & 5 deletions js-packages/web-console/src/lib/components/other/BookADemo.svelte
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
<script lang="ts">
import { useConceptualHq } from '$lib/compositions/useConceptualHq.svelte'
import { captureEvent } from '$lib/services/analytics'
import { bookADemoUrl } from '$lib/services/calendly'
import type { Snippet } from '$lib/types/svelte'

const {
class: _class,
children,
icon = defaultIcon,
triggerLocation
}: { class?: string; children?: Snippet; icon?: Snippet; triggerLocation?: string } = $props()
placement
}: {
class?: string
children?: Snippet
icon?: Snippet
/** Where this button sits, e.g. 'footer'. Reaches Calendly and analytics. */
placement?: string
} = $props()

const calendlyUrl = 'https://calendly.com/d/cqnj-p63-mbq/feldera-demo'
const conceptualHq = useConceptualHq()
const calendlyUrl = $derived(bookADemoUrl({ visitorId: conceptualHq.deviceId, placement }))
</script>

{#snippet defaultIcon()}
Expand All @@ -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?.()}
Expand Down
Original file line number Diff line number Diff line change
@@ -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: () => '<span>Book a demo</span>' }))

// 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()
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)}
>
<span class="py-2 font-semibold">{demo.title}</span>
<!-- <span class="fd fd-arrow-right inline-block w-2 text-[20px]"></span> -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -45,6 +47,7 @@ const demo = {
afterEach(() => {
roleState.current = 'read'
listState.current = []
tryPipeline.mockClear()
})

describe('DemoTile.svelte', () => {
Expand All @@ -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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.<br />
<a
class="block pt-2 underline"
href="https://calendly.com/d/cqnj-p63-mbq/feldera-demo"
href={calendlyUrl}
target="_blank"
rel="noreferrer"
onclick={() =>
captureEvent('calendly_opened', {
url: 'https://calendly.com/d/cqnj-p63-mbq/feldera-demo'
})}>Upgrade</a
captureEvent('calendly_opened', { url: calendlyUrl, placement: calendlyPlacement })}
>Upgrade</a
>
</Popover>
{/if}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ export const useTryPipeline = () => {
const { updatePipelines } = useUpdatePipelineList()
const pipelineList = usePipelineList()
const api = usePipelineManager()
return async (pipeline: Omit<Demo, 'title'>, trigger_location?: string) => {
return async (pipeline: Omit<Demo, 'title'>, 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.
const already_created = (pipelineList.pipelines ?? []).some((p) => p.name === pipeline.name)
captureEvent('demo_opened', {
demo: pipeline.name,
already_created,
trigger_location
placement
})
if (!already_created) {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading