Compare commits

...

1 Commits

Author SHA1 Message Date
Benjamin Lu
f4f84cf789 fix: preserve website attribution on outbound links 2026-05-01 02:10:25 -07:00
4 changed files with 364 additions and 0 deletions

View File

@@ -178,3 +178,43 @@ test.describe('Get started section links @smoke', () => {
await expect(cloudLink).toHaveAttribute('href', 'https://cloud.comfy.org')
})
})
test.describe('Attribution preservation @smoke', () => {
test('decorates owned Comfy links with ad attribution', async ({ page }) => {
await page.goto(
'/?utm_source=google&utm_medium=cpc&utm_campaign=spring&gclid=abc123'
)
await expect(
page
.getByTestId('desktop-nav-cta')
.locator(
'a[href="https://cloud.comfy.org/?utm_source=google&utm_medium=cpc&utm_campaign=spring&gclid=abc123"]'
)
).toBeVisible()
const productCardsSection = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
await expect(
productCardsSection.locator(
'a[href="/cloud?utm_source=google&utm_medium=cpc&utm_campaign=spring&gclid=abc123"]'
)
).toBeVisible()
})
test('uses stored attribution after same-origin navigation', async ({
page
}) => {
await page.goto('/?utm_source=google&utm_medium=cpc')
await page.goto('/cloud')
const cloudCta = page.getByRole('link', {
name: /TRY COMFY CLOUD FOR FREE/i
})
await expect(cloudCta).toHaveAttribute(
'href',
'https://cloud.comfy.org/?utm_source=google&utm_medium=cpc'
)
})
})

View File

@@ -133,9 +133,11 @@ const websiteJsonLd = {
<script>
import { initSmoothScroll, cancelScroll } from '../scripts/smoothScroll'
import { ScrollTrigger } from '../scripts/gsapSetup'
import { initAttributionPersistence } from '../scripts/attribution'
import { initPostHog, capturePageview } from '../scripts/posthog'
initSmoothScroll()
initAttributionPersistence()
if (import.meta.env.PROD) {
initPostHog()

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import {
readAttributionFromSearch,
withPreservedAttribution
} from './attribution'
describe('readAttributionFromSearch', () => {
it('reads UTM parameters and ad click IDs', () => {
const attribution = readAttributionFromSearch(
'?utm_source=google&utm_medium=cpc&utm_campaign=spring&gclid=123&fbclid=456&ignored=value'
)
expect(attribution).toEqual({
utm_source: 'google',
utm_medium: 'cpc',
utm_campaign: 'spring',
gclid: '123',
fbclid: '456'
})
})
it('ignores empty attribution values', () => {
const attribution = readAttributionFromSearch(
'?utm_source=&utm_medium=cpc&gclid='
)
expect(attribution).toEqual({
utm_medium: 'cpc'
})
})
})
describe('withPreservedAttribution', () => {
const attribution = {
utm_source: 'google',
utm_medium: 'cpc',
gclid: 'abc123'
}
it('adds attribution to Cloud links', () => {
expect(
withPreservedAttribution(
'https://cloud.comfy.org',
attribution,
'https://comfy.org'
)
).toBe(
'https://cloud.comfy.org/?utm_source=google&utm_medium=cpc&gclid=abc123'
)
})
it('keeps checkout parameters while replacing hardcoded attribution', () => {
expect(
withPreservedAttribution(
'https://cloud.comfy.org/cloud/subscribe?tier=creator&cycle=monthly&utm_source=workflow_hub',
attribution,
'https://comfy.org'
)
).toBe(
'https://cloud.comfy.org/cloud/subscribe?tier=creator&cycle=monthly&utm_source=google&utm_medium=cpc&gclid=abc123'
)
})
it('keeps relative links relative', () => {
expect(
withPreservedAttribution(
'/cloud#pricing',
attribution,
'https://comfy.org'
)
).toBe('/cloud?utm_source=google&utm_medium=cpc&gclid=abc123#pricing')
})
it('preserves attribution on the workflows app route', () => {
expect(
withPreservedAttribution(
'https://comfy.org/workflows',
attribution,
'https://comfy.org'
)
).toBe(
'https://comfy.org/workflows?utm_source=google&utm_medium=cpc&gclid=abc123'
)
})
it('adds attribution to platform links', () => {
expect(
withPreservedAttribution(
'https://platform.comfy.org/profile/api-keys',
attribution,
'https://comfy.org'
)
).toBe(
'https://platform.comfy.org/profile/api-keys?utm_source=google&utm_medium=cpc&gclid=abc123'
)
})
it('does not add attribution to unrelated external destinations', () => {
expect(
withPreservedAttribution(
'https://docs.comfy.org/api-reference/cloud',
attribution,
'https://comfy.org'
)
).toBe('https://docs.comfy.org/api-reference/cloud')
})
})

View File

@@ -0,0 +1,214 @@
const ATTRIBUTION_STORAGE_KEY = 'comfy_website_attribution'
const CLICK_ID_QUERY_KEYS = [
'im_ref',
'gclid',
'gbraid',
'wbraid',
'fbclid',
'msclkid',
'ttclid',
'li_fat_id'
] as const
const CLICK_ID_QUERY_KEY_SET = new Set<string>(CLICK_ID_QUERY_KEYS)
const ATTRIBUTION_HOSTNAMES = new Set([
'comfy.org',
'www.comfy.org',
'cloud.comfy.org',
'platform.comfy.org'
])
type AttributionParams = Record<string, string>
let attributionPersistenceInitialized = false
function isAttributionQueryKey(key: string): boolean {
return key.startsWith('utm_') || CLICK_ID_QUERY_KEY_SET.has(key)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasAttribution(params: AttributionParams): boolean {
return Object.keys(params).length > 0
}
export function readAttributionFromSearch(search: string): AttributionParams {
const params = new URLSearchParams(search)
const attribution: AttributionParams = {}
for (const [key, value] of params) {
if (value && isAttributionQueryKey(key)) {
attribution[key] = value
}
}
return attribution
}
function readStoredAttribution(storage: Storage): AttributionParams {
try {
const stored = storage.getItem(ATTRIBUTION_STORAGE_KEY)
if (!stored) return {}
const parsed: unknown = JSON.parse(stored)
if (!isRecord(parsed)) return {}
const attribution: AttributionParams = {}
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === 'string' && value && isAttributionQueryKey(key)) {
attribution[key] = value
}
}
return attribution
} catch {
return {}
}
}
function persistAttribution(
storage: Storage,
attribution: AttributionParams
): void {
try {
storage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(attribution))
} catch {
return
}
}
function resolveAttribution(
storage: Storage,
search: string
): AttributionParams {
const stored = readStoredAttribution(storage)
const fromUrl = readAttributionFromSearch(search)
const attribution = { ...stored, ...fromUrl }
if (hasAttribution(fromUrl)) {
persistAttribution(storage, attribution)
}
return attribution
}
function shouldDecorateUrl(url: URL, currentOrigin: string): boolean {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return false
}
if (url.origin === currentOrigin) {
return true
}
return ATTRIBUTION_HOSTNAMES.has(url.hostname.toLowerCase())
}
function isRelativeHref(href: string): boolean {
return href.startsWith('/') || href.startsWith('./') || href.startsWith('../')
}
export function withPreservedAttribution(
href: string,
attribution: AttributionParams,
currentOrigin: string
): string {
if (!hasAttribution(attribution) || href.startsWith('#')) {
return href
}
let url: URL
try {
url = new URL(href, currentOrigin)
} catch {
return href
}
if (!shouldDecorateUrl(url, currentOrigin)) {
return href
}
for (const [key, value] of Object.entries(attribution)) {
url.searchParams.set(key, value)
}
if (isRelativeHref(href)) {
return `${url.pathname}${url.search}${url.hash}`
}
return url.href
}
function decorateLinks(attribution: AttributionParams): void {
const { origin } = window.location
for (const link of document.querySelectorAll<HTMLAnchorElement>('a[href]')) {
const href = link.getAttribute('href')
if (!href) continue
const decoratedHref = withPreservedAttribution(href, attribution, origin)
if (decoratedHref !== href) {
link.setAttribute('href', decoratedHref)
}
}
}
export function initAttributionPersistence(): void {
if (typeof window === 'undefined' || attributionPersistenceInitialized) return
attributionPersistenceInitialized = true
let scheduled = false
const refreshLinks = () => {
scheduled = false
const attribution = resolveAttribution(
window.sessionStorage,
window.location.search
)
decorateLinks(attribution)
}
const scheduleRefresh = () => {
if (scheduled) return
scheduled = true
window.queueMicrotask(refreshLinks)
}
const observer = new MutationObserver(scheduleRefresh)
document.addEventListener('astro:page-load', refreshLinks)
document.addEventListener(
'click',
(event) => {
const target = event.target
if (!(target instanceof Element)) return
const link = target.closest<HTMLAnchorElement>('a[href]')
if (!link) return
const href = link.getAttribute('href')
if (!href) return
const attribution = resolveAttribution(
window.sessionStorage,
window.location.search
)
link.setAttribute(
'href',
withPreservedAttribution(href, attribution, window.location.origin)
)
},
{ capture: true }
)
observer.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['href']
})
refreshLinks()
}