mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
Compare commits
2 Commits
glary/fix-
...
bl/cloud-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1788b6f4c | ||
|
|
f4f84cf789 |
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
108
apps/website/src/scripts/attribution.test.ts
Normal file
108
apps/website/src/scripts/attribution.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
214
apps/website/src/scripts/attribution.ts
Normal file
214
apps/website/src/scripts/attribution.ts
Normal 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()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import { buildCloudLoginRedirectQuery } from './attributionRedirectQuery'
|
||||
|
||||
describe('buildCloudLoginRedirectQuery', () => {
|
||||
test('preserves attribution as top-level login query params', () => {
|
||||
const query: LocationQuery = {
|
||||
utm_source: 'google',
|
||||
utm_medium: 'cpc',
|
||||
gclid: 'abc123',
|
||||
ignored: 'value'
|
||||
}
|
||||
|
||||
expect(
|
||||
buildCloudLoginRedirectQuery(
|
||||
'/?utm_source=google&utm_medium=cpc&gclid=abc123&ignored=value',
|
||||
query
|
||||
)
|
||||
).toEqual({
|
||||
previousFullPath: encodeURIComponent(
|
||||
'/?utm_source=google&utm_medium=cpc&gclid=abc123&ignored=value'
|
||||
),
|
||||
utm_source: 'google',
|
||||
utm_medium: 'cpc',
|
||||
gclid: 'abc123'
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps previousFullPath when no attribution is present', () => {
|
||||
expect(buildCloudLoginRedirectQuery('/cloud/pricing', {})).toEqual({
|
||||
previousFullPath: encodeURIComponent('/cloud/pricing')
|
||||
})
|
||||
})
|
||||
|
||||
test('does not add login query for bare root path', () => {
|
||||
expect(buildCloudLoginRedirectQuery('/', {})).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { LocationQuery, LocationQueryRaw } from 'vue-router'
|
||||
|
||||
const CLICK_ID_QUERY_KEYS = new Set([
|
||||
'im_ref',
|
||||
'gclid',
|
||||
'gbraid',
|
||||
'wbraid',
|
||||
'fbclid',
|
||||
'msclkid',
|
||||
'ttclid',
|
||||
'li_fat_id'
|
||||
])
|
||||
|
||||
function isAttributionQueryKey(key: string): boolean {
|
||||
return key.startsWith('utm_') || CLICK_ID_QUERY_KEYS.has(key)
|
||||
}
|
||||
|
||||
function attributionQueryFrom(query: LocationQuery): LocationQueryRaw {
|
||||
const attributionQuery: LocationQueryRaw = {}
|
||||
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (isAttributionQueryKey(key) && value !== null) {
|
||||
attributionQuery[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return attributionQuery
|
||||
}
|
||||
|
||||
function hasQuery(query: LocationQueryRaw): boolean {
|
||||
return Object.keys(query).length > 0
|
||||
}
|
||||
|
||||
export function buildCloudLoginRedirectQuery(
|
||||
fullPath: string,
|
||||
query: LocationQuery
|
||||
): LocationQueryRaw | undefined {
|
||||
const attributionQuery = attributionQueryFrom(query)
|
||||
|
||||
if (fullPath === '/') {
|
||||
return hasQuery(attributionQuery) ? attributionQuery : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
previousFullPath: encodeURIComponent(fullPath),
|
||||
...attributionQuery
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
|
||||
|
||||
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { buildCloudLoginRedirectQuery } from '@/platform/cloud/onboarding/utils/attributionRedirectQuery'
|
||||
|
||||
const cloudOnboardingRoutes = isCloud
|
||||
? (await import('./platform/cloud/onboarding/onboardingCloudRoutes'))
|
||||
@@ -177,10 +178,7 @@ if (isCloud) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const query =
|
||||
to.fullPath === '/'
|
||||
? undefined
|
||||
: { previousFullPath: encodeURIComponent(to.fullPath) }
|
||||
const query = buildCloudLoginRedirectQuery(to.fullPath, to.query)
|
||||
|
||||
// Check if route requires authentication
|
||||
if (to.meta.requiresAuth && !isLoggedIn) {
|
||||
|
||||
Reference in New Issue
Block a user