From 6ca44eea28fe3410097ba3dc599b09650b416392 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Tue, 30 Dec 2025 18:06:47 +0800 Subject: [PATCH] feat: integrate Google Analytics event tracking and update CSP for script sources (#30365) Co-authored-by: CodingOnStar --- web/app/components/app-initializer.tsx | 38 +++++++++++++- .../base/amplitude/AmplitudeProvider.tsx | 1 + web/app/components/base/ga/index.tsx | 50 +++++++++++-------- web/app/signup/set-password/page.tsx | 26 +++++++++- web/global.d.ts | 20 +++++++- web/utils/gtag.ts | 14 ++++++ 6 files changed, 125 insertions(+), 24 deletions(-) create mode 100644 web/utils/gtag.ts diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx index 0f710abf39..e30646eb3f 100644 --- a/web/app/components/app-initializer.tsx +++ b/web/app/components/app-initializer.tsx @@ -1,14 +1,18 @@ 'use client' import type { ReactNode } from 'react' +import Cookies from 'js-cookie' import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { parseAsString, useQueryState } from 'nuqs' import { useCallback, useEffect, useState } from 'react' import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, } from '@/app/education-apply/constants' import { fetchSetupStatus } from '@/service/common' +import { sendGAEvent } from '@/utils/gtag' import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect' +import { trackEvent } from './base/amplitude' type AppInitializerProps = { children: ReactNode @@ -22,6 +26,10 @@ export const AppInitializer = ({ // Tokens are now stored in cookies, no need to check localStorage const pathname = usePathname() const [init, setInit] = useState(false) + const [oauthNewUser, setOauthNewUser] = useQueryState( + 'oauth_new_user', + parseAsString.withOptions({ history: 'replace' }), + ) const isSetupFinished = useCallback(async () => { try { @@ -45,6 +53,34 @@ export const AppInitializer = ({ (async () => { const action = searchParams.get('action') + if (oauthNewUser === 'true') { + let utmInfo = null + const utmInfoStr = Cookies.get('utm_info') + if (utmInfoStr) { + try { + utmInfo = JSON.parse(utmInfoStr) + } + catch (e) { + console.error('Failed to parse utm_info cookie:', e) + } + } + + // Track registration event with UTM params + trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', { + method: 'oauth', + ...utmInfo, + }) + + sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', { + method: 'oauth', + ...utmInfo, + }) + + // Clean up: remove utm_info cookie and URL params + Cookies.remove('utm_info') + setOauthNewUser(null) + } + if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes') @@ -67,7 +103,7 @@ export const AppInitializer = ({ router.replace('/signin') } })() - }, [isSetupFinished, router, pathname, searchParams]) + }, [isSetupFinished, router, pathname, searchParams, oauthNewUser, setOauthNewUser]) return init ? children : null } diff --git a/web/app/components/base/amplitude/AmplitudeProvider.tsx b/web/app/components/base/amplitude/AmplitudeProvider.tsx index 91c3713a07..87ef516835 100644 --- a/web/app/components/base/amplitude/AmplitudeProvider.tsx +++ b/web/app/components/base/amplitude/AmplitudeProvider.tsx @@ -68,6 +68,7 @@ const AmplitudeProvider: FC = ({ pageViews: true, formInteractions: true, fileDownloads: true, + attribution: true, }, }) diff --git a/web/app/components/base/ga/index.tsx b/web/app/components/base/ga/index.tsx index 2d5fe101d0..eb991092e0 100644 --- a/web/app/components/base/ga/index.tsx +++ b/web/app/components/base/ga/index.tsx @@ -1,3 +1,4 @@ +import type { UnsafeUnwrappedHeaders } from 'next/headers' import type { FC } from 'react' import { headers } from 'next/headers' import Script from 'next/script' @@ -18,45 +19,54 @@ export type IGAProps = { gaType: GaType } -const GA: FC = async ({ +const extractNonceFromCSP = (cspHeader: string | null): string | undefined => { + if (!cspHeader) + return undefined + const nonceMatch = cspHeader.match(/'nonce-([^']+)'/) + return nonceMatch ? nonceMatch[1] : undefined +} + +const GA: FC = ({ gaType, }) => { if (IS_CE_EDITION) return null - const nonce = process.env.NODE_ENV === 'production' ? (await headers()).get('x-nonce') ?? '' : '' + const cspHeader = process.env.NODE_ENV === 'production' + ? (headers() as unknown as UnsafeUnwrappedHeaders).get('content-security-policy') + : null + const nonce = extractNonceFromCSP(cspHeader) return ( <> - + {/* Initialize dataLayer first */} + nonce={nonce} + /> + {/* Load GA script */} + + nonce={nonce} + /> - ) } export default React.memo(GA) diff --git a/web/app/signup/set-password/page.tsx b/web/app/signup/set-password/page.tsx index 4ce69192d5..69af045f1a 100644 --- a/web/app/signup/set-password/page.tsx +++ b/web/app/signup/set-password/page.tsx @@ -1,5 +1,6 @@ 'use client' import type { MailRegisterResponse } from '@/service/use-common' +import Cookies from 'js-cookie' import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -10,6 +11,20 @@ import Toast from '@/app/components/base/toast' import { validPassword } from '@/config' import { useMailRegister } from '@/service/use-common' import { cn } from '@/utils/classnames' +import { sendGAEvent } from '@/utils/gtag' + +const parseUtmInfo = () => { + const utmInfoStr = Cookies.get('utm_info') + if (!utmInfoStr) + return null + try { + return JSON.parse(utmInfoStr) + } + catch (e) { + console.error('Failed to parse utm_info cookie:', e) + return null + } +} const ChangePasswordForm = () => { const { t } = useTranslation() @@ -55,11 +70,18 @@ const ChangePasswordForm = () => { }) const { result } = res as MailRegisterResponse if (result === 'success') { - // Track registration success event - trackEvent('user_registration_success', { + const utmInfo = parseUtmInfo() + trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', { method: 'email', + ...utmInfo, }) + sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', { + method: 'email', + ...utmInfo, + }) + Cookies.remove('utm_info') // Clean up: remove utm_info cookie + Toast.notify({ type: 'success', message: t('api.actionSuccess', { ns: 'common' }), diff --git a/web/global.d.ts b/web/global.d.ts index 0ccadf7887..5d0adcfa09 100644 --- a/web/global.d.ts +++ b/web/global.d.ts @@ -9,4 +9,22 @@ declare module 'lamejs/src/js/Lame'; declare module 'lamejs/src/js/BitStream'; declare module 'react-18-input-autosize'; -export { } +declare global { + // Google Analytics gtag types + type GtagEventParams = { + [key: string]: unknown + } + + type Gtag = { + (command: 'config', targetId: string, config?: GtagEventParams): void + (command: 'event', eventName: string, eventParams?: GtagEventParams): void + (command: 'js', date: Date): void + (command: 'set', config: GtagEventParams): void + } + + // eslint-disable-next-line ts/consistent-type-definitions -- interface required for declaration merging + interface Window { + gtag?: Gtag + dataLayer?: unknown[] + } +} diff --git a/web/utils/gtag.ts b/web/utils/gtag.ts new file mode 100644 index 0000000000..5af51a6564 --- /dev/null +++ b/web/utils/gtag.ts @@ -0,0 +1,14 @@ +/** + * Send Google Analytics event + * @param eventName - event name + * @param eventParams - event params + */ +export const sendGAEvent = ( + eventName: string, + eventParams?: GtagEventParams, +): void => { + if (typeof window === 'undefined' || typeof (window as any).gtag !== 'function') { + return + } + (window as any).gtag('event', eventName, eventParams) +}