diff --git a/.env.example b/.env.example index 1a65352..b03d376 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index e6f88cd..4cc254b 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,9 @@ VITE_AI_API_URL= # 사용할 AI 모델명 VITE_AI_API_MODEL=llama3.1 + +# Google Analytics 4 Measurement ID +VITE_GA_MEASUREMENT_ID=G-JGZ8JFGKPW ``` > **백엔드 API 연동** diff --git a/src/app/router.tsx b/src/app/router.tsx index 597bea7..3ee6f76 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -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' @@ -21,6 +22,7 @@ import { TeamManagement } from '../pages/team-management' export function AppRouter() { return ( + } /> } /> diff --git a/src/features/analytics/model/__tests__/googleAnalytics.test.ts b/src/features/analytics/model/__tests__/googleAnalytics.test.ts new file mode 100644 index 0000000..e7c6b08 --- /dev/null +++ b/src/features/analytics/model/__tests__/googleAnalytics.test.ts @@ -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('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() + }) +}) diff --git a/src/features/analytics/model/googleAnalytics.ts b/src/features/analytics/model/googleAnalytics.ts new file mode 100644 index 0000000..6d0d4e0 --- /dev/null +++ b/src/features/analytics/model/googleAnalytics.ts @@ -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 } +} diff --git a/src/features/analytics/ui/GoogleAnalytics.tsx b/src/features/analytics/ui/GoogleAnalytics.tsx new file mode 100644 index 0000000..c8cf634 --- /dev/null +++ b/src/features/analytics/ui/GoogleAnalytics.tsx @@ -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 +} diff --git a/src/features/analytics/ui/__tests__/GoogleAnalytics.test.tsx b/src/features/analytics/ui/__tests__/GoogleAnalytics.test.tsx new file mode 100644 index 0000000..a6e71d4 --- /dev/null +++ b/src/features/analytics/ui/__tests__/GoogleAnalytics.test.tsx @@ -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( + + + + 캘린더} /> + + , + ) + + 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' })) + }) +})