Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ VITE_API_BASE_URL=http://localhost:8080

# MSW Mock 활성화 (true: mock 사용, false 또는 미설정: 실제 API 사용)
VITE_USE_MOCK=true

# Google Analytics 4 Measurement ID
VITE_GA_MEASUREMENT_ID=G-JGZ8JFGKPW
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ VITE_AI_API_URL=<your-ollama-api-url>

# 사용할 AI 모델명
VITE_AI_API_MODEL=llama3.1

# Google Analytics 4 Measurement ID
VITE_GA_MEASUREMENT_ID=G-JGZ8JFGKPW
```

> **백엔드 API 연동**
Expand Down
2 changes: 2 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { GoogleAnalytics } from '../features/analytics/ui/GoogleAnalytics'
import { Layout } from '../widgets/Layout'
import { PrivateRoute } from '../shared/ui/PrivateRoute'
import { AdminRoute } from '../shared/ui/AdminRoute'
Expand All @@ -21,6 +22,7 @@ import { TeamManagement } from '../pages/team-management'
export function AppRouter() {
return (
<BrowserRouter>
<GoogleAnalytics />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
Expand Down
78 changes: 78 additions & 0 deletions src/features/analytics/model/__tests__/googleAnalytics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createGoogleAnalyticsClient,
getGoogleAnalyticsMeasurementId,
type GoogleAnalyticsClient,
} from '../googleAnalytics'

const environment = { window, document } as const

afterEach(() => {
document.querySelectorAll('script[data-yanus-ga4]').forEach((script) => script.remove())
window.dataLayer = undefined
window.gtag = undefined
})

describe('getGoogleAnalyticsMeasurementId', () => {
it('Given a trimmed GA4 measurement ID, When parsed, Then it returns the normalized ID', () => {
expect(getGoogleAnalyticsMeasurementId(' G-JGZ8JFGKPW ')).toBe('G-JGZ8JFGKPW')
})

it('Given a missing or non-GA4 ID, When parsed, Then it disables analytics', () => {
expect(getGoogleAnalyticsMeasurementId(undefined)).toBeNull()
expect(getGoogleAnalyticsMeasurementId('UA-123456')).toBeNull()
})
})

describe('createGoogleAnalyticsClient', () => {
it('Given a valid ID, When initialized, Then it queues the GA4 config without an automatic page view', () => {
const client = createGoogleAnalyticsClient('G-TEST123', environment)

client.initialize()

expect(document.querySelector('script[data-yanus-ga4]')).not.toBeNull()
expect(document.querySelector<HTMLScriptElement>('script[data-yanus-ga4]')?.src).toContain(
'id=G-TEST123',
)
expect(window.dataLayer).toHaveLength(2)
const configCommand = window.dataLayer?.[1]
expect(configCommand?.[0]).toBe('config')
expect(configCommand?.[1]).toBe('G-TEST123')
expect(configCommand?.[2]).toEqual({ send_page_view: false })
})

it('Given a configured client, When the same route is reported twice, Then it sends one page view per route', () => {
const client = createGoogleAnalyticsClient('G-TEST123', environment)

client.pageView({ path: '/calendar', title: '캘린더', location: 'https://yanus.test/calendar' })
client.pageView({ path: '/calendar', title: '캘린더', location: 'https://yanus.test/calendar' })
client.pageView({ path: '/chat', title: '채팅', location: 'https://yanus.test/chat' })

expect(window.dataLayer?.filter((command) => String(command[0]) === 'event')).toHaveLength(2)
})

it('Given no measurement ID, When analytics is initialized, Then it does not add a script or data layer', () => {
const client = createGoogleAnalyticsClient(null, environment)

client.initialize()
client.pageView({ path: '/', title: '홈', location: 'https://yanus.test/' })

expect(document.querySelector('script[data-yanus-ga4]')).toBeNull()
expect(window.dataLayer).toBeUndefined()
})
})

describe('GoogleAnalyticsClient contract', () => {
it('describes the client methods used by the route tracker', () => {
const client: GoogleAnalyticsClient = {
initialize: vi.fn(),
pageView: vi.fn(),
}

client.initialize()
client.pageView({ path: '/', title: '홈', location: 'https://yanus.test/' })

expect(client.initialize).toHaveBeenCalledOnce()
expect(client.pageView).toHaveBeenCalledOnce()
})
})
98 changes: 98 additions & 0 deletions src/features/analytics/model/googleAnalytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
export type GoogleAnalyticsPageView = Readonly<{
path: string
title: string
location: string
}>

export type GoogleAnalyticsClient = Readonly<{
initialize: () => void
pageView: (page: GoogleAnalyticsPageView) => void
}>

type GoogleAnalyticsConfig = Readonly<{
send_page_view: false
}>

type GoogleAnalyticsPageViewParameters = Readonly<{
page_title: string
page_location: string
page_path: string
}>

type GoogleAnalyticsCommand =
| readonly ['js', Date]
| readonly ['config', string, GoogleAnalyticsConfig]
| readonly ['event', 'page_view', GoogleAnalyticsPageViewParameters]

type GoogleAnalyticsEnvironment = Readonly<{
window: Window
document: Document
}>

declare global {
interface Window {
dataLayer?: GoogleAnalyticsCommand[]
gtag?: (...command: GoogleAnalyticsCommand) => void
}
}

const GA4_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]+$/i
const GOOGLE_ANALYTICS_SCRIPT_SELECTOR = 'script[data-yanus-ga4]'
const GOOGLE_ANALYTICS_SCRIPT_ATTRIBUTE = 'data-yanus-ga4'

export function getGoogleAnalyticsMeasurementId(rawMeasurementId: unknown): string | null {
if (typeof rawMeasurementId !== 'string') {
return null
}

const measurementId = rawMeasurementId.trim()
return GA4_MEASUREMENT_ID_PATTERN.test(measurementId) ? measurementId : null
}

export function createGoogleAnalyticsClient(
measurementId: string | null,
environment: GoogleAnalyticsEnvironment,
): GoogleAnalyticsClient {
let isInitialized = false
let lastPagePath: string | null = null

const initialize = (): void => {
if (!measurementId || isInitialized) {
return
}

environment.window.dataLayer ??= []
environment.window.gtag ??= (...command: GoogleAnalyticsCommand): void => {
environment.window.dataLayer?.push(command)
}

if (!environment.document.querySelector(GOOGLE_ANALYTICS_SCRIPT_SELECTOR)) {
const script = environment.document.createElement('script')
script.async = true
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`
script.setAttribute(GOOGLE_ANALYTICS_SCRIPT_ATTRIBUTE, measurementId)
environment.document.head.appendChild(script)
}

environment.window.gtag?.('js', new Date())
environment.window.gtag?.('config', measurementId, { send_page_view: false })
isInitialized = true
}

const pageView = (page: GoogleAnalyticsPageView): void => {
initialize()

if (!measurementId || lastPagePath === page.path) {
return
}

environment.window.gtag?.('event', 'page_view', {
page_title: page.title,
page_location: page.location,
page_path: page.path,
})
lastPagePath = page.path
}

return { initialize, pageView }
}
34 changes: 34 additions & 0 deletions src/features/analytics/ui/GoogleAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import {
createGoogleAnalyticsClient,
getGoogleAnalyticsMeasurementId,
type GoogleAnalyticsClient,
} from '../model/googleAnalytics'

const measurementId = getGoogleAnalyticsMeasurementId(import.meta.env.VITE_GA_MEASUREMENT_ID)
const defaultClient = createGoogleAnalyticsClient(measurementId, { window, document })

type GoogleAnalyticsProps = Readonly<{
client?: GoogleAnalyticsClient
}>

export function GoogleAnalytics({ client = defaultClient }: GoogleAnalyticsProps) {
const location = useLocation()
const pagePath = location.pathname
const pageLocation = `${window.location.origin}${pagePath}`

useEffect(() => {
client.initialize()
}, [client])

useEffect(() => {
client.pageView({
path: pagePath,
title: document.title,
location: pageLocation,
})
}, [client, pageLocation, pagePath])

return null
}
32 changes: 32 additions & 0 deletions src/features/analytics/ui/__tests__/GoogleAnalytics.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes, Link } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { GoogleAnalytics } from '../GoogleAnalytics'
import type { GoogleAnalyticsClient } from '../../model/googleAnalytics'

describe('GoogleAnalytics route tracker', () => {
it('tracks the initial route and each SPA navigation', async () => {
const user = userEvent.setup()
const client: GoogleAnalyticsClient = {
initialize: vi.fn(),
pageView: vi.fn(),
}

render(
<MemoryRouter initialEntries={['/']}>
<GoogleAnalytics client={client} />
<Routes>
<Route path="*" element={<Link to="/calendar">캘린더</Link>} />
</Routes>
</MemoryRouter>,
)

expect(client.initialize).toHaveBeenCalledOnce()
expect(client.pageView).toHaveBeenCalledWith(expect.objectContaining({ path: '/' }))

await user.click(screen.getByRole('link', { name: '캘린더' }))

expect(client.pageView).toHaveBeenLastCalledWith(expect.objectContaining({ path: '/calendar' }))
})
})
Loading