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..7195e84c65a --- /dev/null +++ b/apps/sim/app/(auth)/signup/complete/page.tsx @@ -0,0 +1,16 @@ +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' + +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..1c98bb18e03 --- /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 { 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' + +interface SocialSignUpCompletionProps { + consentRequired: boolean +} + +function isSignUpConversionMethod(value: string | null): value is SocialSignInProvider | 'email' { + return value === 'email' || 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 && isSignUpConversionMethod(provider)) { + await trackSignUpCompletionConversions({ + marker, + method: 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..fa30df3c98e 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -5,9 +5,9 @@ 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 { 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' @@ -347,8 +347,18 @@ function SignupFormContent({ return } - if (measurement) trackGoogleEvent('sign_up', { method: 'email' }) - if (marketing) trackFreebuffConversion('signup_completed', response.data.user.id) + 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() @@ -357,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. @@ -366,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 new file mode 100644 index 00000000000..e787ecf21cf --- /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(() => { + Reflect.deleteProperty(window, 'twq') + Reflect.deleteProperty(window, 'freebuff') + Reflect.deleteProperty(window, 'gtag') +}) + +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..c08707620b6 --- /dev/null +++ b/apps/sim/lib/auth/social-sign-up-conversion.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options {"url":"https://www.sim.ai/signup"} + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createEmailSignUpCallbackURL, + createSocialSignUpCallbackURL, + trackSignUpCompletionConversions, +} from '@/lib/auth/social-sign-up-conversion' + +afterEach(() => { + sessionStorage.clear() + 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('signup completion 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 trackSignUpCompletionConversions({ + marker, + method: 'google', + userId: 'user-1', + measurement: true, + marketing: true, + }) + const duplicateReport = await trackSignUpCompletionConversions({ + marker, + method: '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 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() + }) + + 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 new file mode 100644 index 00000000000..e7f89d2555c --- /dev/null +++ b/apps/sim/lib/auth/social-sign-up-conversion.ts @@ -0,0 +1,78 @@ +import { generateShortId } from '@sim/utils/id' +import { trackSignUpConversions } from '@/lib/analytics/sign-up' +import type { SocialSignInProvider } from '@/lib/auth/social-sign-in' + +const SIGN_UP_MARKER_STORAGE_KEY = 'socialSignUpConversionMarker' + +type SignUpConversionMethod = SocialSignInProvider | 'email' + +interface SignUpCompletionConversionOptions { + marker: string | null + method: SignUpConversionMethod + userId: string + measurement: boolean + marketing: boolean +} + +function createSignUpCallbackURL( + method: SignUpConversionMethod, + callbackURL: string, + expectedUserId?: string +): string { + const marker = generateShortId() + try { + const storedMarker = expectedUserId ? `${marker}:${expectedUserId}` : marker + sessionStorage.setItem(SIGN_UP_MARKER_STORAGE_KEY, storedMarker) + } catch {} + + const params = new URLSearchParams({ + provider: method, + callbackURL, + conversion: marker, + }) + return `/signup/complete?${params.toString()}` +} + +/** 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 { + 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 trackSignUpCompletionConversions({ + marker, + method, + userId, + measurement, + marketing, +}: SignUpCompletionConversionOptions): Promise { + if (!claimSignUpMarker(marker, userId)) return false + + await trackSignUpConversions({ method, 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