feat: integrate Google Analytics event tracking and update CSP for script sources

- Added types for Google Analytics gtag and implemented event tracking in user registration flows.
- Updated Content Security Policy to allow 'wasm-unsafe-eval' in script sources.
- Refactored GA component to improve nonce handling and script loading strategy.
- Cleaned up UTM info cookies after successful user registration.
This commit is contained in:
CodingOnStar 2025-12-30 14:39:28 +08:00
parent 3505516e8e
commit 59772c2493
7 changed files with 104 additions and 24 deletions

View File

@ -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,26 @@ export const AppInitializer = ({
(async () => {
const action = searchParams.get('action')
if (oauthNewUser === 'true') {
const utmInfoStr = Cookies.get('utm_info')
const utmInfo = utmInfoStr ? JSON.parse(utmInfoStr) : null
// Track registration event with UTM params
trackEvent('user_registration_success', {
method: 'oauth',
...utmInfo,
})
sendGAEvent('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 +95,7 @@ export const AppInitializer = ({
router.replace('/signin')
}
})()
}, [isSetupFinished, router, pathname, searchParams])
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser, setOauthNewUser])
return init ? children : null
}

View File

@ -68,6 +68,7 @@ const AmplitudeProvider: FC<IAmplitudeProps> = ({
pageViews: true,
formInteractions: true,
fileDownloads: true,
attribution: true,
},
})

View File

@ -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<IGAProps> = 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<IGAProps> = ({
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 (
<>
<Script
strategy="beforeInteractive"
async
src={`https://www.googletagmanager.com/gtag/js?id=${gaIdMaps[gaType]}`}
nonce={nonce ?? undefined}
>
</Script>
{/* Initialize dataLayer first */}
<Script
id="ga-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${gaIdMaps[gaType]}');
window.dataLayer = window.dataLayer || [];
window.gtag = function gtag(){window.dataLayer.push(arguments);};
window.gtag('js', new Date());
window.gtag('config', '${gaIdMaps[gaType]}');
`,
}}
nonce={nonce ?? undefined}
>
</Script>
nonce={nonce}
/>
{/* Load GA script */}
<Script
strategy="afterInteractive"
src={`https://www.googletagmanager.com/gtag/js?id=${gaIdMaps[gaType]}`}
nonce={nonce}
/>
{/* Cookie banner */}
<Script
id="cookieyes"
strategy="lazyOnload"
src="https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js"
nonce={nonce ?? undefined}
>
</Script>
nonce={nonce}
/>
</>
)
}
export default React.memo(GA)

View File

@ -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,7 @@ 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 ChangePasswordForm = () => {
const { t } = useTranslation()
@ -55,11 +57,20 @@ const ChangePasswordForm = () => {
})
const { result } = res as MailRegisterResponse
if (result === 'success') {
// Track registration success event
const utmInfoStr = Cookies.get('utm_info')
const utmInfo = utmInfoStr ? JSON.parse(utmInfoStr) : null
trackEvent('user_registration_success', {
method: 'email',
...utmInfo,
})
sendGAEvent('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' }),

20
web/global.d.ts vendored
View File

@ -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[]
}
}

View File

@ -33,7 +33,7 @@ export function middleware(request: NextRequest) {
const cspHeader = `
default-src 'self' ${scheme_source} ${csp} ${whiteList};
connect-src 'self' ${scheme_source} ${csp} ${whiteList};
script-src 'self' ${scheme_source} ${csp} ${whiteList};
script-src 'self' 'wasm-unsafe-eval' ${scheme_source} ${csp} ${whiteList};
style-src 'self' 'unsafe-inline' ${scheme_source} ${whiteList};
worker-src 'self' ${scheme_source} ${csp} ${whiteList};
media-src 'self' ${scheme_source} ${csp} ${whiteList};

12
web/utils/gtag.ts Normal file
View File

@ -0,0 +1,12 @@
/**
* Send Google Analytics event
* @param eventName - event name
* @param eventParams - event params
*/
export const sendGAEvent = (
eventName: string,
eventParams?: GtagEventParams,
): void => {
if (typeof window !== 'undefined' && window.gtag)
window.gtag('event', eventName, eventParams)
}