Skip to content

Commit a25a722

Browse files
authored
feat(analytics): add UTM suite and instance id to on-ramp outbound links (#2075)
* refactor(hooks): extract useInstanceId into a shared hook The react-query call that fetches the instance id from the main process was inlined in useHubSpotForm. Lift it into a dedicated renderer/src/common/hooks/use-instance-id.ts so other renderer surfaces (on-ramp links, analytics) can share the cached value without duplicating the queryKey. * feat(analytics): add buildOnrampDocsUrl helper for on-ramp outbound links Small builder that emits docs URLs with the full UTM suite (utm_source, utm_medium, utm_campaign, utm_content) plus an optional tdi param populated from the instance id. Params are emitted in a stable, documented order; tdi is omitted when the instance id query has not yet resolved so the link remains clickable during the first render. * feat(nav): attach full UTM suite and instance id to Enterprise upgrade link The header "Upgrade to Enterprise" button now routes through buildOnrampDocsUrl with campaign=enterprise-upgrade and content=app-header, and carries the instance id as tdi, so the marketing site can attribute clicks back to the app surface and to the user's install. * feat(registry): attach full UTM suite and instance id to custom registry promo link The "Build a custom registry" promo tile now uses the shared on-ramp URL builder with campaign=custom-registry and content=registry-view-tile, including the instance id as tdi for attribution parity with the Enterprise upgrade CTA. * refactor(hooks): cache useInstanceId for a few days The instance id is stable across restarts but the IPC itself is cheap, so pinning the cache forever is overkill. Set staleTime to three days — enough to avoid the per-mount refetch without locking the value in the client cache for the lifetime of the app. * refactor(nav): scope instance-id fetch to the Enterprise upgrade button Extract the upgrade CTA into its own small component that owns the useInstanceId call. In enterprise builds the button is not rendered, so the hook (and its IPC) no longer runs at all. * refactor(analytics): use URLSearchParams in buildOnrampDocsUrl Replace the hand-rolled map/join serialization with URLSearchParams. For the real UTM values the output is byte-for-byte identical; only the edge-case encoding test needs an update since spaces now serialize as \`+\` instead of \`%20\` per application/x-www-form-urlencoded.
1 parent 50323e0 commit a25a722

8 files changed

Lines changed: 140 additions & 36 deletions

File tree

renderer/src/common/components/layout/top-nav/__tests__/top-nav.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ describe('TopNav', () => {
108108
})
109109
expect(link).toHaveAttribute(
110110
'href',
111-
`${DOCS_BASE_URL}/enterprise?utm_source=${APP_IDENTIFIER}`
111+
`${DOCS_BASE_URL}/enterprise?utm_source=${APP_IDENTIFIER}&utm_medium=app&utm_campaign=enterprise-upgrade&utm_content=app-header&tdi=test-instance-id`
112112
)
113113
})
114114
})

renderer/src/common/components/layout/top-nav/index.tsx

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ import { useFeatureFlag } from '@/common/hooks/use-feature-flag'
3232
import { featureFlagKeys } from '@utils/feature-flags'
3333
import { usePermissions } from '@/common/contexts/permissions'
3434
import { PERMISSION_KEYS } from '@/common/contexts/permissions/permission-keys'
35-
import { APP_IDENTIFIER, DOCS_BASE_URL } from '@common/app-info'
35+
import { buildOnrampDocsUrl } from '@/common/lib/onramp-url'
36+
import { useInstanceId } from '@/common/hooks/use-instance-id'
3637

3738
interface NavButtonProps {
3839
to: string
@@ -119,6 +120,34 @@ function TopNavLinks() {
119120
)
120121
}
121122

123+
function EnterpriseUpgradeButton() {
124+
const { instanceId } = useInstanceId()
125+
const href = buildOnrampDocsUrl('/enterprise', {
126+
campaign: 'enterprise-upgrade',
127+
content: 'app-header',
128+
instanceId,
129+
})
130+
131+
return (
132+
<Button
133+
variant="success"
134+
className="app-region-no-drag rounded-full font-normal"
135+
size="sm"
136+
asChild
137+
>
138+
<a
139+
href={href}
140+
target="_blank"
141+
rel="noopener noreferrer"
142+
onClick={() => trackEvent('Onramp: Upgrade to Enterprise clicked')}
143+
>
144+
<PackageOpen className="size-4" />
145+
Upgrade to Enterprise
146+
</a>
147+
</Button>
148+
)
149+
}
150+
122151
interface TopNavProps extends HTMLProps<HTMLElement> {
123152
isEnterprise?: boolean
124153
}
@@ -166,26 +195,7 @@ export function TopNav({ isEnterprise = false, ...props }: TopNavProps) {
166195
<div
167196
className="app-region-no-drag flex h-full items-center justify-self-end"
168197
>
169-
{!isEnterprise && (
170-
<Button
171-
variant="success"
172-
className="app-region-no-drag rounded-full font-normal"
173-
size="sm"
174-
asChild
175-
>
176-
<a
177-
href={`${DOCS_BASE_URL}/enterprise?utm_source=${APP_IDENTIFIER}`}
178-
target="_blank"
179-
rel="noopener noreferrer"
180-
onClick={() =>
181-
trackEvent('Onramp: Upgrade to Enterprise clicked')
182-
}
183-
>
184-
<PackageOpen className="size-4" />
185-
Upgrade to Enterprise
186-
</a>
187-
</Button>
188-
)}
198+
{!isEnterprise && <EnterpriseUpgradeButton />}
189199
<div className="flex h-full items-center gap-1 pl-2">
190200
{canShow(PERMISSION_KEYS.HELP_MENU) && (
191201
<HelpDropdown

renderer/src/common/hooks/use-hubspot-form.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import { useState } from 'react'
2-
import { useQuery } from '@tanstack/react-query'
32
import { submitToHubSpot } from '../lib/hubspot'
3+
import { useInstanceId } from './use-instance-id'
44

55
export function useHubSpotForm(formId: string, pageName: string) {
66
const [consentToProcess, setConsentToProcess] = useState(false)
77

8-
const { data: instanceId, isFetched } = useQuery({
9-
queryKey: ['instance-id'],
10-
queryFn: () => window.electronAPI.getInstanceId(),
11-
})
8+
const { instanceId, isFetched } = useInstanceId()
129

1310
const isReady = isFetched && !!instanceId
1411

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
3+
const THREE_DAYS_MS = 1000 * 60 * 60 * 24 * 3
4+
5+
export function useInstanceId() {
6+
const { data: instanceId, isFetched } = useQuery({
7+
queryKey: ['instance-id'],
8+
queryFn: () => window.electronAPI.getInstanceId(),
9+
staleTime: THREE_DAYS_MS,
10+
})
11+
12+
return { instanceId, isFetched }
13+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { APP_IDENTIFIER, DOCS_BASE_URL } from '@common/app-info'
3+
import { buildOnrampDocsUrl } from '../onramp-url'
4+
5+
describe('buildOnrampDocsUrl', () => {
6+
it('emits utm params in the documented order with tdi when instanceId is provided', () => {
7+
const url = buildOnrampDocsUrl('/enterprise', {
8+
campaign: 'enterprise-upgrade',
9+
content: 'app-header',
10+
instanceId: 'abc-123',
11+
})
12+
13+
expect(url).toBe(
14+
`${DOCS_BASE_URL}/enterprise?utm_source=${APP_IDENTIFIER}&utm_medium=app&utm_campaign=enterprise-upgrade&utm_content=app-header&tdi=abc-123`
15+
)
16+
})
17+
18+
it('omits the tdi param when instanceId is undefined', () => {
19+
const url = buildOnrampDocsUrl('/guides-registry/', {
20+
campaign: 'custom-registry',
21+
content: 'registry-view-tile',
22+
})
23+
24+
expect(url).toBe(
25+
`${DOCS_BASE_URL}/guides-registry/?utm_source=${APP_IDENTIFIER}&utm_medium=app&utm_campaign=custom-registry&utm_content=registry-view-tile`
26+
)
27+
expect(url).not.toContain('tdi=')
28+
})
29+
30+
it('omits the tdi param when instanceId is an empty string', () => {
31+
const url = buildOnrampDocsUrl('/enterprise', {
32+
campaign: 'enterprise-upgrade',
33+
content: 'app-header',
34+
instanceId: '',
35+
})
36+
37+
expect(url).not.toContain('tdi=')
38+
})
39+
40+
it('url-encodes param values using application/x-www-form-urlencoded', () => {
41+
const url = buildOnrampDocsUrl('/enterprise', {
42+
campaign: 'enterprise upgrade',
43+
content: 'app/header',
44+
instanceId: 'id with space',
45+
})
46+
47+
expect(url).toContain('utm_campaign=enterprise+upgrade')
48+
expect(url).toContain('utm_content=app%2Fheader')
49+
expect(url).toContain('tdi=id+with+space')
50+
})
51+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { APP_IDENTIFIER, DOCS_BASE_URL } from '@common/app-info'
2+
3+
type OnrampUrlOptions = {
4+
campaign: string
5+
content: string
6+
instanceId?: string
7+
}
8+
9+
export function buildOnrampDocsUrl(
10+
path: string,
11+
{ campaign, content, instanceId }: OnrampUrlOptions
12+
): string {
13+
const params = new URLSearchParams([
14+
['utm_source', APP_IDENTIFIER],
15+
['utm_medium', 'app'],
16+
['utm_campaign', campaign],
17+
['utm_content', content],
18+
])
19+
20+
if (instanceId) {
21+
params.append('tdi', instanceId)
22+
}
23+
24+
return `${DOCS_BASE_URL}${path}?${params.toString()}`
25+
}

renderer/src/features/registry-servers/components/card-registry-promo.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@ import {
77
} from '@/common/components/ui/card'
88
import { Button } from '@/common/components/ui/button'
99
import { trackEvent } from '@/common/lib/analytics'
10-
import { APP_IDENTIFIER, DOCS_BASE_URL } from '@common/app-info'
11-
12-
const REGISTRY_DOCS_URL = `${DOCS_BASE_URL}/guides-registry/?utm_source=${APP_IDENTIFIER}`
10+
import { buildOnrampDocsUrl } from '@/common/lib/onramp-url'
11+
import { useInstanceId } from '@/common/hooks/use-instance-id'
1312

1413
export function CardRegistryPromo() {
14+
const { instanceId } = useInstanceId()
15+
const registryDocsUrl = buildOnrampDocsUrl('/guides-registry/', {
16+
campaign: 'custom-registry',
17+
content: 'registry-view-tile',
18+
instanceId,
19+
})
20+
1521
return (
1622
<Card className="bg-brand-green-mid gap-0 border-none p-4">
1723
<CardHeader className="px-0">
@@ -36,7 +42,7 @@ export function CardRegistryPromo() {
3642
hover:bg-brand-green-dark/90 rounded-full p-4 font-medium"
3743
>
3844
<a
39-
href={REGISTRY_DOCS_URL}
45+
href={registryDocsUrl}
4046
target="_blank"
4147
rel="noopener noreferrer"
4248
onClick={() => trackEvent('Onramp: custom registry docs clicked')}

renderer/src/routes/__tests__/registry.test.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,13 @@ describe('Promo Card', () => {
108108
expect(screen.getByText('Build a custom registry')).toBeVisible()
109109
})
110110

111-
const link = screen.getByRole('link', { name: /learn how/i })
112-
expect(link).toHaveAttribute(
113-
'href',
114-
`${DOCS_BASE_URL}/guides-registry/?utm_source=${APP_IDENTIFIER}`
115-
)
111+
await waitFor(() => {
112+
const link = screen.getByRole('link', { name: /learn how/i })
113+
expect(link).toHaveAttribute(
114+
'href',
115+
`${DOCS_BASE_URL}/guides-registry/?utm_source=${APP_IDENTIFIER}&utm_medium=app&utm_campaign=custom-registry&utm_content=registry-view-tile&tdi=test-instance-id`
116+
)
117+
})
116118
})
117119

118120
it('tracks event when CTA is clicked', async () => {

0 commit comments

Comments
 (0)