From d06b77e0171c8a14e68a8cb8d319e6dfef2422b4 Mon Sep 17 00:00:00 2001 From: Sim Pi Agent Date: Sun, 18 Oct 2026 23:51:47 +0000 Subject: [PATCH 1/2] feat(analytics): report account signups to X Ads --- apps/sim/app/(auth)/signup/complete/page.tsx | 11 +++ .../complete/social-sign-up-completion.tsx | 62 +++++++++++++++ apps/sim/app/(auth)/signup/layout.tsx | 16 ++++ .../(auth)/signup/signup-consent-tracking.tsx | 9 +++ apps/sim/app/(auth)/signup/signup-form.tsx | 11 ++- apps/sim/lib/analytics/sign-up.test.ts | 43 ++++++++++ apps/sim/lib/analytics/sign-up.ts | 38 +++++++++ apps/sim/lib/auth/social-sign-in.ts | 7 +- .../auth/social-sign-up-conversion.test.ts | 78 +++++++++++++++++++ .../sim/lib/auth/social-sign-up-conversion.ts | 59 ++++++++++++++ apps/sim/lib/consent/scripts.ts | 1 + 11 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/(auth)/signup/complete/page.tsx create mode 100644 apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx create mode 100644 apps/sim/app/(auth)/signup/layout.tsx create mode 100644 apps/sim/app/(auth)/signup/signup-consent-tracking.tsx create mode 100644 apps/sim/lib/analytics/sign-up.test.ts create mode 100644 apps/sim/lib/analytics/sign-up.ts create mode 100644 apps/sim/lib/auth/social-sign-up-conversion.test.ts create mode 100644 apps/sim/lib/auth/social-sign-up-conversion.ts diff --git a/apps/sim/app/(auth)/signup/complete/page.tsx b/apps/sim/app/(auth)/signup/complete/page.tsx new file mode 100644 index 00000000000..3dc609fe928 --- /dev/null +++ b/apps/sim/app/(auth)/signup/complete/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' +import { isHosted } from '@/lib/core/config/env-flags' +import { SocialSignUpCompletion } from '@/app/(auth)/signup/complete/social-sign-up-completion' + +export const metadata: Metadata = { + title: 'Finishing Sign Up', +} + +export default function SocialSignUpCompletePage() { + return +} diff --git a/apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx b/apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx new file mode 100644 index 00000000000..b40d1fd08e6 --- /dev/null +++ b/apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useSearchParams } from 'next/navigation' +import { useSession } from '@/lib/auth/client' +import type { SocialSignInProvider } from '@/lib/auth/social-sign-in' +import { trackSocialSignUpConversions } from '@/lib/auth/social-sign-up-conversion' +import { useTrackingConsent } from '@/lib/consent/tracking-consent' +import { validateCallbackUrl } from '@/lib/core/security/input-validation' +import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' + +interface SocialSignUpCompletionProps { + consentRequired: boolean +} + +function isSocialSignInProvider(value: string | null): value is SocialSignInProvider { + return value === 'github' || value === 'google' || value === 'microsoft' +} + +export function SocialSignUpCompletion({ consentRequired }: SocialSignUpCompletionProps) { + const redirectStartedRef = useRef(false) + const searchParams = useSearchParams() + const { data: session, isPending } = useSession() + const { isResolved, measurement, marketing } = useTrackingConsent() + const callbackURL = searchParams.get('callbackURL') + const destination = + callbackURL && validateCallbackUrl(callbackURL) ? callbackURL : DEFAULT_POST_AUTH_ROUTE + const marker = searchParams.get('conversion') + const provider = searchParams.get('provider') + + useEffect(() => { + if (redirectStartedRef.current || isPending || (consentRequired && !isResolved)) return + redirectStartedRef.current = true + + const finishSignUp = async () => { + if (session?.user && isSocialSignInProvider(provider)) { + await trackSocialSignUpConversions({ + marker, + provider, + userId: session.user.id, + measurement: consentRequired && measurement, + marketing: consentRequired && marketing, + }) + } + window.location.href = destination + } + + void finishSignUp() + }, [ + consentRequired, + destination, + isPending, + isResolved, + marker, + measurement, + marketing, + provider, + session, + ]) + + return

Finishing signup…

+} diff --git a/apps/sim/app/(auth)/signup/layout.tsx b/apps/sim/app/(auth)/signup/layout.tsx new file mode 100644 index 00000000000..e7ca9492383 --- /dev/null +++ b/apps/sim/app/(auth)/signup/layout.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' +import { isHosted } from '@/lib/core/config/env-flags' +import { SignupConsentTracking } from '@/app/(auth)/signup/signup-consent-tracking' + +interface SignupLayoutProps { + children: ReactNode +} + +export default function SignupLayout({ children }: SignupLayoutProps) { + return ( + <> + {children} + {isHosted && } + + ) +} diff --git a/apps/sim/app/(auth)/signup/signup-consent-tracking.tsx b/apps/sim/app/(auth)/signup/signup-consent-tracking.tsx new file mode 100644 index 00000000000..0d0697181d0 --- /dev/null +++ b/apps/sim/app/(auth)/signup/signup-consent-tracking.tsx @@ -0,0 +1,9 @@ +'use client' + +import { useConsentScript } from '@c15t/nextjs/headless' +import { X_PIXEL_SCRIPT } from '@/lib/consent/scripts' + +export function SignupConsentTracking() { + useConsentScript({ script: X_PIXEL_SCRIPT, unmountBehavior: 'keep' }) + return null +} diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 8eedc7f0d91..9302b5048a9 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -5,8 +5,7 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { createLogger } from '@sim/logger' import { useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' -import { trackFreebuffConversion } from '@/lib/analytics/freebuff' -import { trackGoogleEvent } from '@/lib/analytics/google' +import { trackSignUpConversions } from '@/lib/analytics/sign-up' import { client, useSession } from '@/lib/auth/client' import { useTrackingConsent } from '@/lib/consent/tracking-consent' import { getEnv, isFalsy } from '@/lib/core/config/env' @@ -347,8 +346,12 @@ function SignupFormContent({ return } - if (measurement) trackGoogleEvent('sign_up', { method: 'email' }) - if (marketing) trackFreebuffConversion('signup_completed', response.data.user.id) + await trackSignUpConversions({ + method: 'email', + userId: response.data.user.id, + measurement, + marketing, + }) try { await refetchSession() diff --git a/apps/sim/lib/analytics/sign-up.test.ts b/apps/sim/lib/analytics/sign-up.test.ts new file mode 100644 index 00000000000..041a46a75bc --- /dev/null +++ b/apps/sim/lib/analytics/sign-up.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options {"url":"https://www.sim.ai/signup"} + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { trackSignUpConversions } from '@/lib/analytics/sign-up' + +afterEach(() => { + window.twq = undefined + window.freebuff = undefined + window.gtag = undefined +}) + +describe('email signup conversions', () => { + it('reports the X signup event when marketing consent is granted', async () => { + const trackXEvent = vi.fn() + window.twq = trackXEvent + + await trackSignUpConversions({ + method: 'email', + userId: 'user-1', + measurement: false, + marketing: true, + }) + + expect(trackXEvent).toHaveBeenCalledOnce() + expect(trackXEvent).toHaveBeenCalledWith('event', 'tw-q5xbl-rhis8', {}) + }) + + it('does not report the X signup event without marketing consent', async () => { + const trackXEvent = vi.fn() + window.twq = trackXEvent + + await trackSignUpConversions({ + method: 'email', + userId: 'user-1', + measurement: true, + marketing: false, + }) + + expect(trackXEvent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/analytics/sign-up.ts b/apps/sim/lib/analytics/sign-up.ts new file mode 100644 index 00000000000..5c7e02ce483 --- /dev/null +++ b/apps/sim/lib/analytics/sign-up.ts @@ -0,0 +1,38 @@ +import { sleep } from '@sim/utils/helpers' +import { trackFreebuffConversion } from '@/lib/analytics/freebuff' +import { trackGoogleEvent } from '@/lib/analytics/google' +import { X_SIGN_UP_EVENT_ID } from '@/lib/consent/scripts' + +const X_PIXEL_STUB_WAIT_INTERVAL_MS = 50 +const X_PIXEL_STUB_WAIT_ATTEMPTS = 20 + +interface TrackSignUpConversionsOptions { + method: string + userId: string + measurement: boolean + marketing: boolean +} + +async function trackXSignUpConversion(): Promise { + for (let attempt = 0; attempt < X_PIXEL_STUB_WAIT_ATTEMPTS; attempt += 1) { + if (typeof window.twq === 'function') { + window.twq('event', X_SIGN_UP_EVENT_ID, {}) + return + } + await sleep(X_PIXEL_STUB_WAIT_INTERVAL_MS) + } +} + +/** Reports a completed signup only to vendors allowed by the settled consent state. */ +export async function trackSignUpConversions({ + method, + userId, + measurement, + marketing, +}: TrackSignUpConversionsOptions): Promise { + if (measurement) trackGoogleEvent('sign_up', { method }) + if (!marketing) return + + trackFreebuffConversion('signup_completed', userId) + await trackXSignUpConversion() +} diff --git a/apps/sim/lib/auth/social-sign-in.ts b/apps/sim/lib/auth/social-sign-in.ts index dd146ab9912..9a96fde15ee 100644 --- a/apps/sim/lib/auth/social-sign-in.ts +++ b/apps/sim/lib/auth/social-sign-in.ts @@ -1,4 +1,5 @@ import { client } from '@/lib/auth/client' +import { createSocialSignUpCallbackURL } from '@/lib/auth/social-sign-up-conversion' import { captureClientEvent } from '@/lib/posthog/client' import type { PostHogEventMap } from '@/lib/posthog/events' @@ -25,5 +26,9 @@ export function startSocialSignIn({ callbackURL, }: StartSocialSignInOptions) { captureClientEvent('external_sign_in_started', { provider, view, surface }) - return client.signIn.social({ provider, callbackURL }) + return client.signIn.social({ + provider, + callbackURL, + newUserCallbackURL: createSocialSignUpCallbackURL(provider, callbackURL), + }) } diff --git a/apps/sim/lib/auth/social-sign-up-conversion.test.ts b/apps/sim/lib/auth/social-sign-up-conversion.test.ts new file mode 100644 index 00000000000..61904ea5d97 --- /dev/null +++ b/apps/sim/lib/auth/social-sign-up-conversion.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options {"url":"https://www.sim.ai/signup"} + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createSocialSignUpCallbackURL, + trackSocialSignUpConversions, +} from '@/lib/auth/social-sign-up-conversion' + +afterEach(() => { + sessionStorage.clear() + window.twq = undefined + window.freebuff = undefined + window.gtag = undefined +}) + +function getConversionMarker(callbackURL: string): string { + return new URL(callbackURL, window.location.origin).searchParams.get('conversion') ?? '' +} + +describe('social signup conversions', () => { + it('reports a new social user once', async () => { + vi.stubEnv('NODE_ENV', 'production') + const trackXEvent = vi.fn() + const trackGoogleEvent = vi.fn() + const trackFreebuffEvent = vi.fn() + window.twq = trackXEvent + window.gtag = trackGoogleEvent + window.freebuff = trackFreebuffEvent + const marker = getConversionMarker(createSocialSignUpCallbackURL('google', '/workspace')) + + const firstReport = await trackSocialSignUpConversions({ + marker, + provider: 'google', + userId: 'user-1', + measurement: true, + marketing: true, + }) + const duplicateReport = await trackSocialSignUpConversions({ + marker, + provider: 'google', + userId: 'user-1', + measurement: true, + marketing: true, + }) + + expect(firstReport).toBe(true) + expect(duplicateReport).toBe(false) + expect(trackXEvent).toHaveBeenCalledOnce() + expect(trackGoogleEvent).toHaveBeenCalledWith( + 'event', + 'sign_up', + expect.objectContaining({ method: 'google' }) + ) + expect(trackFreebuffEvent).toHaveBeenCalledOnce() + }) + + it( + 'does not report a returning social login that lacks the new-user callback marker', + async () => { + const trackXEvent = vi.fn() + window.twq = trackXEvent + createSocialSignUpCallbackURL('github', '/workspace') + + const reported = await trackSocialSignUpConversions({ + marker: 'not-the-pending-new-user-marker', + provider: 'github', + userId: 'user-1', + measurement: true, + marketing: true, + }) + + expect(reported).toBe(false) + expect(trackXEvent).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/auth/social-sign-up-conversion.ts b/apps/sim/lib/auth/social-sign-up-conversion.ts new file mode 100644 index 00000000000..75d304633ce --- /dev/null +++ b/apps/sim/lib/auth/social-sign-up-conversion.ts @@ -0,0 +1,59 @@ +import { generateShortId } from '@sim/utils/id' +import { trackSignUpConversions } from '@/lib/analytics/sign-up' +import type { SocialSignInProvider } from '@/lib/auth/social-sign-in' + +const SOCIAL_SIGN_UP_MARKER_STORAGE_KEY = 'socialSignUpConversionMarker' + +interface SocialSignUpConversionOptions { + marker: string | null + provider: SocialSignInProvider + userId: string + measurement: boolean + marketing: boolean +} + +/** Creates Better Auth's new-user-only destination and remembers this browser initiated it. */ +export function createSocialSignUpCallbackURL( + provider: SocialSignInProvider, + callbackURL: string +): string { + const marker = generateShortId() + try { + sessionStorage.setItem(SOCIAL_SIGN_UP_MARKER_STORAGE_KEY, marker) + } catch {} + + const params = new URLSearchParams({ + provider, + callbackURL, + conversion: marker, + }) + return `/signup/complete?${params.toString()}` +} + +function claimSocialSignUpMarker(marker: string | null): boolean { + if (!marker) return false + + try { + if (sessionStorage.getItem(SOCIAL_SIGN_UP_MARKER_STORAGE_KEY) !== marker) return false + sessionStorage.removeItem(SOCIAL_SIGN_UP_MARKER_STORAGE_KEY) + return true + } catch { + return false + } +} + +/** + * Claims a new-user marker before reporting so reloads and remounts cannot duplicate the signup. + */ +export async function trackSocialSignUpConversions({ + marker, + provider, + userId, + measurement, + marketing, +}: SocialSignUpConversionOptions): Promise { + if (!claimSocialSignUpMarker(marker)) return false + + await trackSignUpConversions({ method: provider, userId, measurement, marketing }) + return true +} diff --git a/apps/sim/lib/consent/scripts.ts b/apps/sim/lib/consent/scripts.ts index c84a73391b7..24d78989e8c 100644 --- a/apps/sim/lib/consent/scripts.ts +++ b/apps/sim/lib/consent/scripts.ts @@ -22,6 +22,7 @@ export const GOOGLE_ANALYTICS_ID = 'G-QB9D67TBHR' as const export const GOOGLE_ADS_ID = 'AW-17916292239' as const export const X_PIXEL_ID = 'q5xbl' as const export const X_DEMO_BOOKED_EVENT_ID = 'tw-q5xbl-q5xbn' as const +export const X_SIGN_UP_EVENT_ID = 'tw-q5xbl-rhis8' as const const AHREFS_ANALYTICS_KEY = 'WJ9yWTBAiQKZAE/2TyU/yA' as const From 70515f3a53f834e5e4a4c1d26f0c1bb079f8b3f2 Mon Sep 17 00:00:00 2001 From: Sim Pi Agent Date: Mon, 19 Oct 2026 00:02:58 +0000 Subject: [PATCH 2/2] Pi Babysit: address PR #9212 feedback --- apps/sim/app/(auth)/signup/complete/page.tsx | 7 +- .../complete/social-sign-up-completion.tsx | 12 ++-- apps/sim/app/(auth)/signup/signup-form.tsx | 27 +++++--- apps/sim/lib/analytics/sign-up.test.ts | 6 +- .../auth/social-sign-up-conversion.test.ts | 67 ++++++++++++------- .../sim/lib/auth/social-sign-up-conversion.ts | 61 +++++++++++------ 6 files changed, 114 insertions(+), 66 deletions(-) diff --git a/apps/sim/app/(auth)/signup/complete/page.tsx b/apps/sim/app/(auth)/signup/complete/page.tsx index 3dc609fe928..7195e84c65a 100644 --- a/apps/sim/app/(auth)/signup/complete/page.tsx +++ b/apps/sim/app/(auth)/signup/complete/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { Suspense } from 'react' import { isHosted } from '@/lib/core/config/env-flags' import { SocialSignUpCompletion } from '@/app/(auth)/signup/complete/social-sign-up-completion' @@ -7,5 +8,9 @@ export const metadata: Metadata = { } export default function SocialSignUpCompletePage() { - return + return ( + + + + ) } diff --git a/apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx b/apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx index b40d1fd08e6..1c98bb18e03 100644 --- a/apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx +++ b/apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef } from 'react' import { useSearchParams } from 'next/navigation' import { useSession } from '@/lib/auth/client' import type { SocialSignInProvider } from '@/lib/auth/social-sign-in' -import { trackSocialSignUpConversions } from '@/lib/auth/social-sign-up-conversion' +import { trackSignUpCompletionConversions } from '@/lib/auth/social-sign-up-conversion' import { useTrackingConsent } from '@/lib/consent/tracking-consent' import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' @@ -13,8 +13,8 @@ interface SocialSignUpCompletionProps { consentRequired: boolean } -function isSocialSignInProvider(value: string | null): value is SocialSignInProvider { - return value === 'github' || value === 'google' || value === 'microsoft' +function isSignUpConversionMethod(value: string | null): value is SocialSignInProvider | 'email' { + return value === 'email' || value === 'github' || value === 'google' || value === 'microsoft' } export function SocialSignUpCompletion({ consentRequired }: SocialSignUpCompletionProps) { @@ -33,10 +33,10 @@ export function SocialSignUpCompletion({ consentRequired }: SocialSignUpCompleti redirectStartedRef.current = true const finishSignUp = async () => { - if (session?.user && isSocialSignInProvider(provider)) { - await trackSocialSignUpConversions({ + if (session?.user && isSignUpConversionMethod(provider)) { + await trackSignUpCompletionConversions({ marker, - provider, + method: provider, userId: session.user.id, measurement: consentRequired && measurement, marketing: consentRequired && marketing, diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 9302b5048a9..fa30df3c98e 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { trackSignUpConversions } from '@/lib/analytics/sign-up' import { client, useSession } from '@/lib/auth/client' +import { createEmailSignUpCallbackURL } from '@/lib/auth/social-sign-up-conversion' import { useTrackingConsent } from '@/lib/consent/tracking-consent' import { getEnv, isFalsy } from '@/lib/core/config/env' import { isSsoEnabled } from '@/lib/core/config/env-flags' @@ -346,12 +347,18 @@ function SignupFormContent({ return } - await trackSignUpConversions({ - method: 'email', - userId: response.data.user.id, - measurement, - marketing, - }) + const destination = resolvePostSignupDestination({ emailVerificationEnabled, redirectUrl }) + + // Verification-enabled duplicate signups return synthetic success, so those conversions + // wait until the OTP-created session matches the user ID returned here. + if (!emailVerificationEnabled) { + await trackSignUpConversions({ + method: 'email', + userId: response.data.user.id, + measurement, + marketing, + }) + } try { await refetchSession() @@ -360,8 +367,6 @@ function SignupFormContent({ logger.error('Failed to refresh session after signup:', sessionError) } - const destination = resolvePostSignupDestination({ emailVerificationEnabled, redirectUrl }) - if (typeof window !== 'undefined') { // Clear any leftover from an earlier signup in this tab — otherwise a // signup with no callbackUrl inherits the previous CLI/invite destination. @@ -369,8 +374,12 @@ function SignupFormContent({ sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) if (destination.kind === 'verify') { + const postVerificationDestination = redirectUrl || DEFAULT_POST_AUTH_ROUTE sessionStorage.setItem('verificationEmail', emailValue) - if (redirectUrl) sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl) + sessionStorage.setItem( + POST_AUTH_REDIRECT_STORAGE_KEY, + createEmailSignUpCallbackURL(response.data.user.id, postVerificationDestination) + ) } } diff --git a/apps/sim/lib/analytics/sign-up.test.ts b/apps/sim/lib/analytics/sign-up.test.ts index 041a46a75bc..e787ecf21cf 100644 --- a/apps/sim/lib/analytics/sign-up.test.ts +++ b/apps/sim/lib/analytics/sign-up.test.ts @@ -6,9 +6,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { trackSignUpConversions } from '@/lib/analytics/sign-up' afterEach(() => { - window.twq = undefined - window.freebuff = undefined - window.gtag = undefined + Reflect.deleteProperty(window, 'twq') + Reflect.deleteProperty(window, 'freebuff') + Reflect.deleteProperty(window, 'gtag') }) describe('email signup conversions', () => { diff --git a/apps/sim/lib/auth/social-sign-up-conversion.test.ts b/apps/sim/lib/auth/social-sign-up-conversion.test.ts index 61904ea5d97..c08707620b6 100644 --- a/apps/sim/lib/auth/social-sign-up-conversion.test.ts +++ b/apps/sim/lib/auth/social-sign-up-conversion.test.ts @@ -4,22 +4,23 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' import { + createEmailSignUpCallbackURL, createSocialSignUpCallbackURL, - trackSocialSignUpConversions, + trackSignUpCompletionConversions, } from '@/lib/auth/social-sign-up-conversion' afterEach(() => { sessionStorage.clear() - window.twq = undefined - window.freebuff = undefined - window.gtag = undefined + Reflect.deleteProperty(window, 'twq') + Reflect.deleteProperty(window, 'freebuff') + Reflect.deleteProperty(window, 'gtag') }) function getConversionMarker(callbackURL: string): string { return new URL(callbackURL, window.location.origin).searchParams.get('conversion') ?? '' } -describe('social signup conversions', () => { +describe('signup completion conversions', () => { it('reports a new social user once', async () => { vi.stubEnv('NODE_ENV', 'production') const trackXEvent = vi.fn() @@ -30,16 +31,16 @@ describe('social signup conversions', () => { window.freebuff = trackFreebuffEvent const marker = getConversionMarker(createSocialSignUpCallbackURL('google', '/workspace')) - const firstReport = await trackSocialSignUpConversions({ + const firstReport = await trackSignUpCompletionConversions({ marker, - provider: 'google', + method: 'google', userId: 'user-1', measurement: true, marketing: true, }) - const duplicateReport = await trackSocialSignUpConversions({ + const duplicateReport = await trackSignUpCompletionConversions({ marker, - provider: 'google', + method: 'google', userId: 'user-1', measurement: true, marketing: true, @@ -56,23 +57,37 @@ describe('social signup conversions', () => { expect(trackFreebuffEvent).toHaveBeenCalledOnce() }) - it( - 'does not report a returning social login that lacks the new-user callback marker', - async () => { - const trackXEvent = vi.fn() - window.twq = trackXEvent - createSocialSignUpCallbackURL('github', '/workspace') + it('does not report a returning social login that lacks the new-user callback marker', async () => { + const trackXEvent = vi.fn() + window.twq = trackXEvent + createSocialSignUpCallbackURL('github', '/workspace') - const reported = await trackSocialSignUpConversions({ - marker: 'not-the-pending-new-user-marker', - provider: 'github', - userId: 'user-1', - measurement: true, - marketing: true, - }) + const reported = await trackSignUpCompletionConversions({ + marker: 'not-the-pending-new-user-marker', + method: 'github', + userId: 'user-1', + measurement: true, + marketing: true, + }) + + expect(reported).toBe(false) + expect(trackXEvent).not.toHaveBeenCalled() + }) - expect(reported).toBe(false) - expect(trackXEvent).not.toHaveBeenCalled() - } - ) + it('does not report a duplicate email signup with a synthetic user ID', async () => { + const trackXEvent = vi.fn() + window.twq = trackXEvent + const marker = getConversionMarker(createEmailSignUpCallbackURL('synthetic-user', '/workspace')) + + const reported = await trackSignUpCompletionConversions({ + marker, + method: 'email', + userId: 'existing-user', + measurement: true, + marketing: true, + }) + + expect(reported).toBe(false) + expect(trackXEvent).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/auth/social-sign-up-conversion.ts b/apps/sim/lib/auth/social-sign-up-conversion.ts index 75d304633ce..e7f89d2555c 100644 --- a/apps/sim/lib/auth/social-sign-up-conversion.ts +++ b/apps/sim/lib/auth/social-sign-up-conversion.ts @@ -2,58 +2,77 @@ import { generateShortId } from '@sim/utils/id' import { trackSignUpConversions } from '@/lib/analytics/sign-up' import type { SocialSignInProvider } from '@/lib/auth/social-sign-in' -const SOCIAL_SIGN_UP_MARKER_STORAGE_KEY = 'socialSignUpConversionMarker' +const SIGN_UP_MARKER_STORAGE_KEY = 'socialSignUpConversionMarker' -interface SocialSignUpConversionOptions { +type SignUpConversionMethod = SocialSignInProvider | 'email' + +interface SignUpCompletionConversionOptions { marker: string | null - provider: SocialSignInProvider + method: SignUpConversionMethod userId: string measurement: boolean marketing: boolean } -/** Creates Better Auth's new-user-only destination and remembers this browser initiated it. */ -export function createSocialSignUpCallbackURL( - provider: SocialSignInProvider, - callbackURL: string +function createSignUpCallbackURL( + method: SignUpConversionMethod, + callbackURL: string, + expectedUserId?: string ): string { const marker = generateShortId() try { - sessionStorage.setItem(SOCIAL_SIGN_UP_MARKER_STORAGE_KEY, marker) + const storedMarker = expectedUserId ? `${marker}:${expectedUserId}` : marker + sessionStorage.setItem(SIGN_UP_MARKER_STORAGE_KEY, storedMarker) } catch {} const params = new URLSearchParams({ - provider, + provider: method, callbackURL, conversion: marker, }) return `/signup/complete?${params.toString()}` } -function claimSocialSignUpMarker(marker: string | null): boolean { +/** Creates Better Auth's new-user-only destination and remembers this browser initiated it. */ +export function createSocialSignUpCallbackURL( + provider: SocialSignInProvider, + callbackURL: string +): string { + return createSignUpCallbackURL(provider, callbackURL) +} + +/** Defers email conversion reporting until verification proves the returned user is real. */ +export function createEmailSignUpCallbackURL(userId: string, callbackURL: string): string { + return createSignUpCallbackURL('email', callbackURL, userId) +} + +function claimSignUpMarker(marker: string | null, userId: string): boolean { if (!marker) return false try { - if (sessionStorage.getItem(SOCIAL_SIGN_UP_MARKER_STORAGE_KEY) !== marker) return false - sessionStorage.removeItem(SOCIAL_SIGN_UP_MARKER_STORAGE_KEY) - return true + const storedMarker = sessionStorage.getItem(SIGN_UP_MARKER_STORAGE_KEY) + if (!storedMarker) return false + + const [pendingMarker, expectedUserId] = storedMarker.split(':', 2) + if (pendingMarker !== marker) return false + + sessionStorage.removeItem(SIGN_UP_MARKER_STORAGE_KEY) + return !expectedUserId || expectedUserId === userId } catch { return false } } -/** - * Claims a new-user marker before reporting so reloads and remounts cannot duplicate the signup. - */ -export async function trackSocialSignUpConversions({ +/** Claims a new-user marker before reporting so reloads and remounts cannot duplicate the signup. */ +export async function trackSignUpCompletionConversions({ marker, - provider, + method, userId, measurement, marketing, -}: SocialSignUpConversionOptions): Promise { - if (!claimSocialSignUpMarker(marker)) return false +}: SignUpCompletionConversionOptions): Promise { + if (!claimSignUpMarker(marker, userId)) return false - await trackSignUpConversions({ method: provider, userId, measurement, marketing }) + await trackSignUpConversions({ method, userId, measurement, marketing }) return true }