diff --git a/.gitignore b/.gitignore index 76b8e4d5..70c917f7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ yarn-error.log* .idea *storybook.log + +# specs +/specs diff --git a/docs/analytics-dashboard.md b/docs/analytics-dashboard.md new file mode 100644 index 00000000..6abbbf81 --- /dev/null +++ b/docs/analytics-dashboard.md @@ -0,0 +1,436 @@ +# Amplitude 핵심 지표 대시보드 해석 가이드 + +> 대시보드: **Debate Timer - 핵심 지표 대시보드** +> 기간 기본값: Last 30 Days +> 날짜 범위는 대시보드 상단 필터에서 자유롭게 변경 가능 (Custom range로 수집 시작일~오늘 전체 조회 가능) + +--- + +## 목차 + +- [이벤트 타입 참조](#이벤트-타입-참조) + +1. [E1. 회원/비회원 활성 사용자 비율](#e1-회원비회원-활성-사용자-비율) +2. [E6. 회원/비회원 토론 완주 전환 퍼널](#e6-회원비회원-토론-완주-전환-퍼널) +3. [E2. 페이지별 평균 체류 시간](#e2-페이지별-평균-체류-시간) +4. [E5. 시간표 공유 횟수 (회원/비회원별)](#e5-시간표-공유-횟수-회원비회원별) +5. [E4. 비회원→회원 전환 퍼널](#e4-비회원회원-전환-퍼널) +6. [E3. 로그인 완료 경로 분석](#e3-로그인-완료-경로-분석) +7. [E7. 템플릿 선택→사용 전환율](#e7-템플릿-선택사용-전환율) +8. [E8. 피드백 타이머 활용율](#e8-피드백-타이머-활용율) +9. [템플릿별 이용 횟수](#템플릿별-이용-횟수) +10. [E9-a. 투표 생성→결과 확인 퍼널](#e9-a-투표-생성결과-확인-퍼널) + +--- + +## 이벤트 타입 참조 + +차트를 이해하기 전에, Debate Timer가 수집하는 이벤트의 종류와 각 이벤트가 어떤 사용자 행동에서 발생하는지 먼저 파악해두면 지표 해석이 훨씬 쉬워집니다. + +### 공통 속성 (모든 이벤트에 자동 포함) + +| 속성 | 값 | 설명 | +|------|----|------| +| `user_type` | `member` / `guest` | 로그인 여부. `localStorage`의 accessToken 존재 여부로 판단 | +| `language` | `ko` / `en` / ... | 현재 서비스 언어 | +| `page_path` | `/overview/:type/:id` 등 | 이벤트 발화 시점의 URL 경로 (동적 ID는 `:id` 등으로 정규화) | + +--- + +### 페이지 탐색 이벤트 + +| 이벤트 | 발생 시점 | 주요 속성 | +|--------|-----------|-----------| +| `page_view` | 페이지 진입 시 (SPA 라우팅 포함) | `page_title`, `previous_page_path`, `referrer` | +| `page_leave` | 페이지 이탈 시 (다른 페이지 이동, 탭 닫기, 브라우저 종료) | `page_path`, `duration_ms` (체류 시간, ms 단위) | + +--- + +### 인증 이벤트 + +| 이벤트 | 발생 시점 | 주요 속성 | +|--------|-----------|-----------| +| `login_started` | 로그인 버튼 클릭 시 | `trigger_context` (어느 UI에서 로그인을 시도했는지), `trigger_page` | +| `login_completed` | Google OAuth 인증 완료 후 앱으로 복귀 시 | `trigger_context`, `trigger_page`, `member_id` | + +**`trigger_context` 값 목록** + +| 값 | 설명 | +|----|------| +| `landing_header` | 랜딩 페이지 헤더의 로그인 버튼 | +| `landing_table_section` | 랜딩 페이지 시간표 섹션의 로그인 유도 버튼 | +| `timer_modal` | 타이머 페이지 내 저장 유도 모달의 로그인 버튼 | +| `share_save` | 공유받은 시간표 저장 시 로그인 유도 | +| `protected_route` | 로그인 필요 페이지 접근 시 자동 리다이렉트 | +| `unknown` | 그 외 미분류 경로 | + +--- + +### 시간표 공유 이벤트 + +| 이벤트 | 발생 시점 | 주요 속성 | +|--------|-----------|-----------| +| `table_shared` | 시간표 상세 페이지에서 공유 버튼 클릭 시 | `table_id` | +| `share_link_entered` | 공유 링크(`/share?data=...`)로 직접 접속 시 (템플릿 진입 제외) | `referrer` | + +--- + +### 토론 타이머 이벤트 + +| 이벤트 | 발생 시점 | 주요 속성 | +|--------|-----------|-----------| +| `timer_started` | 타이머 페이지 진입 후 데이터 로드 완료 시 (토론 시작) | `table_id`, `total_rounds` | +| `debate_completed` | 마지막 라운드까지 타이머가 종료되어 토론을 완주했을 때 | `table_id`, `total_rounds` | +| `debate_abandoned` | 토론 중 페이지를 이탈했을 때 | `table_id`, `current_round`, `total_rounds`, `abandon_type` | + +**`abandon_type` 값 목록** + +| 값 | 설명 | +|----|------| +| `navigation` | 앱 내 다른 페이지로 이동 (SPA 라우팅) | +| `unload` | 탭 닫기 또는 브라우저 종료 | +| `visibility` | 탭을 백그라운드로 전환 (화면이 숨겨짐) | + +--- + +### 템플릿 이벤트 + +| 이벤트 | 발생 시점 | 주요 속성 | +|--------|-----------|-----------| +| `template_selected` | 랜딩 페이지 템플릿 카드의 "토론하기" 버튼 클릭 시 | `organization_name`, `template_name`, `template_label` | +| `template_used` | 템플릿 경로로 진입한 뒤 타이머가 실제로 시작될 때 | `organization_name`, `template_name`, `template_label`, `table_id` | + +> `template_selected`와 `template_used` 사이에는 시간표 미리보기 → 커스터마이즈 단계가 있으므로 이탈이 발생할 수 있음. 이 둘의 차이가 E7 퍼널 전환율이다. +> +> `template_label`은 `"{organization_name} - {template_name}"` 형태의 조합 속성 (예: `"KEDA - 3 vs 3 통상토론대회 형식"`) + +--- + +### 투표 이벤트 + +| 이벤트 | 발생 시점 | 주요 속성 | +|--------|-----------|-----------| +| `poll_created` | 토론 종료 페이지에서 투표 생성 버튼 클릭 시 (주최자) | `table_id`, `poll_id` | +| `poll_voted` | 참가자가 투표 페이지에서 팀 선택 후 제출 시 | `poll_id`, `team` | +| `poll_result_viewed` | 투표 결과 페이지에 진입했을 때 (주최자) | `poll_id` | + +--- + +### 피드백 이벤트 + +| 이벤트 | 발생 시점 | 주요 속성 | +|--------|-----------|-----------| +| `feedback_timer_started` | 토론 종료 페이지에서 피드백 타이머 시작 버튼 클릭 시 | `table_id` | + +--- + +## E1. 회원/비회원 활성 사용자 비율 + +### 지표 정의 + +| 항목 | 내용 | +|------|------| +| 이벤트 | `page_view` | +| 측정 방식 | Unique Users (고유 사용자 수) | +| 그룹 기준 | `user_type` (`member` / `guest` / `none`) | +| 차트 유형 | 세분화 (선 그래프) | + +### 해석 방법 + +- **member**: 로그인한 회원의 방문 수 +- **guest**: 비로그인 상태로 방문한 사용자 수 +- **(none)**: `user_type` 속성이 없는 이벤트. 구 GA 기반 analytics 코드(`setupGoogleAnalytics.tsx`) 사용 시기에 수집된 과거 데이터로, 신규 `analyticsManager` 전환 이후에는 더 이상 발생하지 않음 + +**핵심 질문**: 전체 방문자 중 회원 비율이 얼마나 되는가? + +- 회원 비율이 높을수록 재방문 충성 사용자가 많다는 신호 +- guest 비율이 압도적이면 신규 유입은 많지만 가입 전환이 낮다는 신호 +- 시간 흐름에 따라 member 비율이 증가하면 서비스 성숙도가 높아지는 것 + +### 주의사항 + +- 한 사람이 같은 날 여러 페이지를 보더라도 1명으로 집계됨 (Unique Users 기준) +- 로그인/비로그인 전환 시 동일 사용자가 두 세그먼트에 중복 집계될 수 있음 + +--- + +## E6. 회원/비회원 토론 완주 전환 퍼널 + +### 지표 정의 + +| 항목 | 내용 | +|------|------| +| 퍼널 스텝 | `timer_started` → `debate_completed` | +| 측정 방식 | 세그먼트별 퍼널 전환율 | +| 세그먼트 | `member` vs `guest` | +| 차트 유형 | 퍼널 | + +### 해석 방법 + +각 막대는 해당 스텝에 도달한 사용자 비율을 나타냄. + +- **`timer_started`**: 타이머를 시작한 사용자 (토론 시작) +- **`debate_completed`**: 모든 라운드를 완료한 사용자 (토론 완주) + +**핵심 질문**: 회원과 비회원 중 누가 토론을 더 완주하는가? + +- member 전환율이 높으면 → 회원이 서비스를 더 목적 있게 활용함 +- guest 전환율이 높으면 → 비회원도 충분히 완주 가능한 UX임을 의미 +- 두 그룹의 전환율 차이가 클수록 회원 유도 메시지를 강화할 근거가 됨 + +### 주의사항 + +- 퍼널은 기본적으로 30일 내 두 이벤트를 순서대로 발화한 사용자를 집계 +- 중간에 이탈 후 재시작한 경우도 완주로 집계될 수 있음 + +--- + +## E2. 페이지별 평균 체류 시간 + +### 지표 정의 + +| 항목 | 내용 | +|------|------| +| 이벤트 | `page_leave` | +| 측정 방식 | `duration_ms` 속성 평균 | +| 그룹 기준 | `page_path` (URL 경로) | +| 차트 유형 | 세분화 (선 그래프) | + +### 해석 방법 + +각 선은 특정 페이지에서의 평균 체류 시간(밀리초)을 나타냄. + +- **`/`**: 랜딩 페이지 +- **`/home`**: 홈 화면 +- **`/overview/customize/:id`**: 시간표 커스터마이즈 페이지 +- **`/vote/end`**: 투표 종료 페이지 + +**핵심 질문**: 어느 페이지에서 사용자가 가장 오래 머무는가? + +- 체류 시간이 길면 해당 페이지 콘텐츠에 높은 관심도를 가짐 +- 예상보다 체류 시간이 짧으면 이탈 또는 UX 문제 가능성 +- 타이머 페이지(`/timer` 등)는 토론 시간만큼 체류 시간이 길어야 정상 + +### 주의사항 + +- `duration_ms`는 `page_leave` 이벤트 발화 시 계산되므로, 탭을 닫거나 브라우저를 종료하면 `pagehide` 이벤트로 수집됨 +- 매우 긴 체류 시간은 탭을 열어두고 자리를 비운 경우일 수 있음 (이상값 존재 가능) +- 단위는 밀리초(ms). 1,000ms = 1초, 60,000ms = 1분 + +--- + +## E5. 시간표 공유 횟수 (회원/비회원별) + +### 지표 정의 + +| 항목 | 내용 | +|------|------| +| 이벤트 | `table_shared` | +| 측정 방식 | 이벤트 총합 (Event Totals) | +| 그룹 기준 | `user_type` | +| 차트 유형 | 세분화 (선 그래프) | + +### 해석 방법 + +- **member**: 회원이 공유한 횟수 +- **guest**: 비회원이 공유한 횟수 + +**핵심 질문**: 회원과 비회원 중 누가 시간표를 더 많이 공유하는가? + +- guest 공유가 많으면 → 비회원도 공유 기능을 충분히 활용함 +- member 공유가 압도적이면 → 공유는 재방문 사용자의 행동 패턴임 +- 공유 횟수 자체가 늘면 바이럴 성장 지표로 볼 수 있음 + +### 주의사항 + +- 이벤트 총합이므로 한 사람이 여러 번 공유하면 여러 번 집계됨 +- `table_shared`는 공유 버튼 클릭 시점에 발화 (실제 링크 접속 여부는 별도 추적 필요) + +--- + +## E4. 비회원→회원 전환 퍼널 + +### 지표 정의 + +| 항목 | 내용 | +|------|------| +| 퍼널 스텝 | `login_started` → `login_completed` | +| 측정 방식 | 퍼널 전환율 | +| 세그먼트 | 모든 사용자 | +| 차트 유형 | 퍼널 | + +### 해석 방법 + +- **`login_started`**: 로그인 버튼을 클릭해 로그인 플로우를 시작한 사용자 +- **`login_completed`**: 실제로 로그인을 완료한 사용자 + +**핵심 질문**: 로그인을 시도한 사람 중 실제로 완료하는 비율은? + +- 전환율이 낮으면 → 로그인 과정에서 이탈이 많다는 신호 (UX 개선 필요) +- 전환율이 높으면 → 로그인 플로우가 원활하게 작동함 +- `login_started` 자체가 적으면 → 로그인 유도 시점/방식 재검토 필요 + +### 주의사항 + +- OAuth 방식(Google 로그인 등)의 경우 팝업 차단이나 외부 페이지 이탈로 인해 `login_completed`가 누락될 수 있음 +- E3(로그인 완료 경로 분석)과 함께 보면 어느 진입점에서 전환이 잘 되는지 파악 가능 + +--- + +## E3. 로그인 완료 경로 분석 + +### 지표 정의 + +| 항목 | 내용 | +|------|------| +| 이벤트 | `login_completed` | +| 측정 방식 | 이벤트 총합 (Event Totals) | +| 그룹 기준 | `trigger_context` (로그인 유발 맥락) | +| 차트 유형 | 세분화 (선 그래프) | + +### 해석 방법 + +`trigger_context`는 사용자가 어느 맥락에서 로그인을 완료했는지를 나타냄. + +예시 값: +- `landing_header`: 랜딩 페이지 헤더의 로그인 버튼 +- `landing_table_section`: 랜딩 페이지 테이블 섹션 CTA에서 로그인 +- `timer_modal`: 타이머에서 저장 유도로 로그인 +- `share_save`: 공유 기능 사용 시 로그인 유도 +- `protected_route`: 인증이 필요한 페이지 진입 시 로그인 + +**핵심 질문**: 어느 상황에서 로그인 전환이 가장 많이 일어나는가? + +- 특정 `trigger_context`의 비율이 높으면 해당 시점의 로그인 유도가 효과적임 +- 비율이 낮은 맥락은 유도 메시지 개선 여지가 있음 +- E4(퍼널)와 함께 보면 시도 대비 완료율을 경로별로 비교 가능 + +### 주의사항 + +- `trigger_context` 값은 코드에서 `login_started` 발화 시 함께 전달되는 속성 +- 새로운 로그인 진입점 추가 시 반드시 `trigger_context` 값을 정의해야 정확한 분석 가능 + +--- + +## E7. 템플릿 선택→사용 전환율 + +### 지표 정의 + +| 항목 | 내용 | +|------|------| +| 퍼널 스텝 | `template_selected` → `template_used` | +| 측정 방식 | 퍼널 전환율 | +| 세그먼트 | 모든 사용자 | +| 차트 유형 | 퍼널 | + +### 해석 방법 + +- **`template_selected`**: 템플릿 카드를 클릭해 선택한 사용자 +- **`template_used`**: 실제로 해당 템플릿으로 토론을 시작한 사용자 + +**핵심 질문**: 템플릿을 클릭한 사람 중 실제로 사용하는 비율은? + +- 전환율이 높으면 → 템플릿 선택 후 진입 장벽이 낮고 바로 사용하기 좋은 UX +- 전환율이 낮으면 → 선택 후 커스터마이즈 단계나 설명이 복잡해 이탈 가능성 +- "템플릿별 이용 횟수" 차트와 함께 보면 어떤 템플릿이 클릭도 많고 사용도 많은지 파악 가능 + +--- + +## E8. 피드백 타이머 활용율 + +### 지표 정의 + +| 항목 | 내용 | +|------|------| +| 퍼널 스텝 | `debate_completed` → `feedback_timer_started` | +| 측정 방식 | 퍼널 전환율 | +| 세그먼트 | 모든 사용자 | +| 차트 유형 | 퍼널 | + +### 해석 방법 + +- **`debate_completed`**: 토론을 완주한 사용자 +- **`feedback_timer_started`**: 토론 완료 후 피드백 타이머 기능을 시작한 사용자 + +**핵심 질문**: 토론을 마친 사람 중 피드백 타이머까지 활용하는 비율은? + +- 전환율이 높으면 → 피드백 기능이 토론 이후 자연스러운 다음 단계로 인식됨 +- 전환율이 낮으면 → 피드백 기능의 존재를 모르거나 필요성을 느끼지 못할 수 있음 +- 피드백 기능 개선/홍보 효과를 이 차트로 측정 가능 + +--- + +## 템플릿별 이용 횟수 + +### 지표 정의 + +| 항목 | 내용 | +|------|------| +| 이벤트 | `template_used` | +| 측정 방식 | 이벤트 총합 (Event Totals) | +| 그룹 기준 | `template_name` | +| 차트 유형 | 세분화 (선 그래프) | + +### 해석 방법 + +각 선이 특정 템플릿의 사용 횟수를 나타냄. + +**핵심 질문**: 어떤 템플릿이 가장 많이 사용되는가? + +- 특정 템플릿이 압도적으로 많으면 → 해당 토론 형식이 인기 있거나, 기본값으로 노출되고 있음 +- 사용이 적은 템플릿은 → 노출 방식 개선 또는 콘텐츠 보완 필요 +- E7(선택→사용 전환율)과 함께 보면 클릭은 많지만 실제 사용이 적은 템플릿을 식별 가능 + +### 주의사항 + +- `template_name`은 각 템플릿 카드에 정의된 이름값으로 집계됨 +- 새 템플릿 추가 시 `template_name`을 일관된 형식으로 설정해야 정확한 비교 가능 + +--- + +## E9-a. 투표 생성→결과 확인 퍼널 + +### 지표 정의 + +| 항목 | 내용 | +|------|------| +| 퍼널 스텝 | `poll_created` → `poll_result_viewed` | +| 측정 방식 | 퍼널 전환율 | +| 세그먼트 | 모든 사용자 (주최자 기준) | +| 차트 유형 | 퍼널 | + +### 해석 방법 + +- **`poll_created`**: 주최자가 투표를 생성한 시점 +- **`poll_result_viewed`**: 투표 결과를 확인한 시점 + +**핵심 질문**: 투표를 만든 주최자가 실제로 결과까지 확인하는 비율은? + +- 전환율이 높으면 → 투표 → 결과 확인까지 플로우가 매끄럽게 연결됨 +- 전환율이 낮으면 → 투표 생성 후 결과 화면 진입이 어렵거나, 참가자 참여가 적어 결과를 볼 유인이 없을 수 있음 +- 시간이 지나며 전환율이 오르면 → 투표 기능이 정착되고 있다는 신호 + +### 주의사항 + +- `poll_created`와 `poll_result_viewed`는 주최자(토론 방장)의 이벤트 +- QR 코드로 참가한 일반 참가자의 `poll_voted` 이벤트와는 별개의 유저 플로우 + +--- + +## 종합 해석 프레임워크 + +### 5가지 핵심 질문과 관련 차트 + +| 질문 | 관련 차트 | +|------|-----------| +| Q1. 회원/비회원 비율 및 체류율은? | E1, E2 | +| Q2. 비회원이 어떤 경로로 회원 전환하는가? | E4, E3 | +| Q3. 시간표 공유는 누가 더 많이 하는가? | E5 | +| Q4. 회원과 비회원의 토론 완주 전환율 차이는? | E6 | +| Q5. 어떤 템플릿이 얼마나 이용되는가? | 템플릿별 이용 횟수, E7 | + +### 데이터가 쌓이기 전 주의사항 + +- 서비스 초기에는 이벤트 수가 적어 퍼널 전환율이 불안정할 수 있음 +- 최소 2~4주 데이터가 쌓인 후 해석하는 것을 권장 +- 단발성 이상값(이벤트 급증/급감)은 배포, 마케팅, 버그 등 외부 요인 확인 필요 diff --git a/package-lock.json b/package-lock.json index 7bbf7a21..2cdbd723 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@amplitude/analytics-browser": "^2.39.0", "@sentry/react": "^10.50.0", "@stomp/stompjs": "^7.3.0", "@tanstack/eslint-plugin-query": "^5.91.4", @@ -117,6 +118,102 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@amplitude/analytics-browser": { + "version": "2.39.2", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.39.2.tgz", + "integrity": "sha512-kSSAnnKK+2jSxBBrz7PGpeO1U+o1LPmmV4Jx4/S9kNZmPHQnxLZGwgtsX5YJYTvopJGRf2LFD83HArA2PrWuDw==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.45.0", + "@amplitude/plugin-autocapture-browser": "1.25.2", + "@amplitude/plugin-custom-enrichment-browser": "0.1.4", + "@amplitude/plugin-network-capture-browser": "1.9.13", + "@amplitude/plugin-page-url-enrichment-browser": "0.7.5", + "@amplitude/plugin-page-view-tracking-browser": "2.9.6", + "@amplitude/plugin-web-vitals-browser": "1.1.28", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/analytics-connector": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz", + "integrity": "sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==", + "license": "MIT" + }, + "node_modules/@amplitude/analytics-core": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.45.0.tgz", + "integrity": "sha512-vWRYbXu2Grs1GM+WHo03RPtbaPs5sJm21YQcAow9JASvtoY4xNqItIeRydCJQWtFHhbbxY41n+CVW6mzDP6aBA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", + "@types/zen-observable": "0.8.3", + "safe-json-stringify": "1.2.0", + "tslib": "^2.4.1", + "zen-observable": "0.10.0" + } + }, + "node_modules/@amplitude/plugin-autocapture-browser": { + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.25.2.tgz", + "integrity": "sha512-AWzIX0uit60Q742rH/96/n88e+3BaVZa4+7Xs+BeuuIOyrljOZlQKzH23Lxzkl0DgbNb5+MMqWds0pov3DV5TA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.45.0", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-custom-enrichment-browser": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-custom-enrichment-browser/-/plugin-custom-enrichment-browser-0.1.4.tgz", + "integrity": "sha512-vxuQocn8YGE2wMLZUmotRG8c6RijoaQAsHKDQEO56CNk3WhSecgSGMnlHcUcOYIzwfXKFj4MxRJS386kdDHV+Q==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.45.0", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-network-capture-browser": { + "version": "1.9.13", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.9.13.tgz", + "integrity": "sha512-8uzTQFbP+dvqJX+S39KqKw+EheJW8JCWT/xlXT55vtTU/ZTFeF074QnHFEKUPewpYXpwKXgJky8PDoMk0b46Qw==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.45.0", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-page-url-enrichment-browser": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-url-enrichment-browser/-/plugin-page-url-enrichment-browser-0.7.5.tgz", + "integrity": "sha512-0Q7P5vsue/s92i3zevVDVJf9AiHkbxGdwkB8iV2oWgkXtglzWugwr//qN+muHmXdi1ZWxRjm93CW+jQJVripgw==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.45.0", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-page-view-tracking-browser": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.9.6.tgz", + "integrity": "sha512-/4lG2lXIB6qbQNf1VYQ5fDOnvInPEtYuOgvmyLfuZ6PvHVFUu4NZtoOVdAcy0R9x76rNyCpRXxdL78p9Ra1ANA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.45.0", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-web-vitals-browser": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-web-vitals-browser/-/plugin-web-vitals-browser-1.1.28.tgz", + "integrity": "sha512-gs4Y1eOuVUEDwYEJF82f/GmgQ7iM4Y/eZTkftJKjFsBNbrPro2CuLymfdAcC+QuVfyrp3qAiWcSGnjDXA6ZbQg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.45.0", + "tslib": "^2.4.1", + "web-vitals": "5.1.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", @@ -2017,7 +2114,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2031,7 +2127,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2045,7 +2140,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2059,7 +2153,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2073,7 +2166,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2087,7 +2179,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2101,7 +2192,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2115,7 +2205,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2129,7 +2218,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2143,7 +2231,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2157,7 +2244,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2171,7 +2257,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2185,7 +2270,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2199,7 +2283,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2213,7 +2296,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2227,7 +2309,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2241,7 +2322,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2255,7 +2335,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2269,7 +2348,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2283,7 +2361,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2297,7 +2374,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2311,7 +2387,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2325,7 +2400,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2339,7 +2413,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2353,7 +2426,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3797,6 +3869,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/zen-observable": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz", + "integrity": "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", @@ -6925,7 +7003,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -10461,6 +10538,12 @@ ], "license": "MIT" }, + "node_modules/safe-json-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", + "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -12848,6 +12931,12 @@ "node": ">=18" } }, + "node_modules/web-vitals": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", + "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -13223,6 +13312,12 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zen-observable": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.10.0.tgz", + "integrity": "sha512-iI3lT0iojZhKwT5DaFy2Ce42n3yFcLdFyOh01G7H0flMY60P8MJuVFEoJoNwXlmAyQ45GrjL6AcZmmlv8A5rbw==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 3f5d74bc..566a8671 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "i18n:transform": "tsx scripts/i18nTransform.ts" }, "dependencies": { + "@amplitude/analytics-browser": "^2.39.0", "@sentry/react": "^10.50.0", "@stomp/stompjs": "^7.3.0", "@tanstack/eslint-plugin-query": "^5.91.4", diff --git a/src/apis/axiosInstance.ts b/src/apis/axiosInstance.ts index 6e052ae7..14190626 100644 --- a/src/apis/axiosInstance.ts +++ b/src/apis/axiosInstance.ts @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/react'; import { getAccessToken, removeAccessToken, + removeMemberId, setAccessToken, } from '../util/accessToken'; import i18n from '../i18n'; @@ -11,6 +12,7 @@ import { DEFAULT_LANG, isSupportedLang, } from '../util/languageRouting'; +import { analyticsManager } from '../util/analytics'; // Get current mode (DEV, PROD or TEST) const currentMode = import.meta.env.MODE; @@ -124,6 +126,8 @@ axiosInstance.interceptors.response.use( Sentry.setUser(null); window.location.href = buildLangPath('/home', lang); removeAccessToken(); + removeMemberId(); + analyticsManager.reset(); return Promise.reject(refreshError); } } diff --git a/src/hooks/mutations/useLogout.ts b/src/hooks/mutations/useLogout.ts index cbcdfddf..21914409 100644 --- a/src/hooks/mutations/useLogout.ts +++ b/src/hooks/mutations/useLogout.ts @@ -1,7 +1,8 @@ import * as Sentry from '@sentry/react'; import { useMutation } from '@tanstack/react-query'; import { logout } from '../../apis/apis/member'; -import { removeAccessToken } from '../../util/accessToken'; +import { removeAccessToken, removeMemberId } from '../../util/accessToken'; +import { analyticsManager } from '../../util/analytics'; export default function useLogout(onSuccess: () => void) { return useMutation({ @@ -11,6 +12,8 @@ export default function useLogout(onSuccess: () => void) { }, onSuccess: () => { removeAccessToken(); + removeMemberId(); + analyticsManager.reset(); Sentry.setUser(null); onSuccess(); }, diff --git a/src/hooks/mutations/usePostUser.ts b/src/hooks/mutations/usePostUser.ts index 7dd5b0cc..90e5eaf4 100644 --- a/src/hooks/mutations/usePostUser.ts +++ b/src/hooks/mutations/usePostUser.ts @@ -1,11 +1,22 @@ import { useMutation } from '@tanstack/react-query'; import { PostUserResponseType } from '../../apis/responses/member'; import { postUser } from '../../apis/apis/member'; +import { setMemberId } from '../../util/accessToken'; +import { analyticsManager } from '../../util/analytics'; +import { DEFAULT_LANG } from '../../util/languageRouting'; export function usePostUser(onSuccess: (data: PostUserResponseType) => void) { return useMutation({ mutationFn: (code: string) => postUser(code), - onSuccess, + onSuccess: (data) => { + setMemberId(data.id); + analyticsManager.setUserId(String(data.id)); + analyticsManager.setUserProperties({ + user_type: 'member', + language: document.documentElement.lang || DEFAULT_LANG, + }); + onSuccess(data); + }, onError: (error) => { console.error('User creation error:', error); }, diff --git a/src/hooks/useAnalytics.test.ts b/src/hooks/useAnalytics.test.ts new file mode 100644 index 00000000..6252bd36 --- /dev/null +++ b/src/hooks/useAnalytics.test.ts @@ -0,0 +1,71 @@ +import { renderHook } from '@testing-library/react'; +import useAnalytics from './useAnalytics'; +import { analyticsManager } from '../util/analytics'; + +vi.mock('../util/analytics', () => ({ + analyticsManager: { + trackEvent: vi.fn(), + trackPageView: vi.fn(), + setUserId: vi.fn(), + setUserProperties: vi.fn(), + reset: vi.fn(), + flush: vi.fn(), + }, +})); + +describe('useAnalytics', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('이벤트 정보를 분석 매니저로 그대로 전달한다', () => { + const { result } = renderHook(() => useAnalytics()); + + result.current.trackEvent('template_selected', { + organization_name: 'org', + template_name: 'tmpl', + template_label: 'org - tmpl', + }); + + expect(analyticsManager.trackEvent).toHaveBeenCalledWith( + 'template_selected', + expect.objectContaining({ + organization_name: 'org', + template_name: 'tmpl', + }), + ); + }); + + test('페이지뷰를 기록하면 페이지 정보가 분석 매니저로 전달된다', () => { + const { result } = renderHook(() => useAnalytics()); + + result.current.trackPageView({ + page_title: 'Home', + previous_page_path: '', + referrer: '', + }); + + expect(analyticsManager.trackPageView).toHaveBeenCalledWith( + expect.objectContaining({ page_title: 'Home' }), + ); + }); + + test('멤버를 식별하면 ID는 문자열로 변환되고 사용자 타입은 member로 기록된다', () => { + const { result } = renderHook(() => useAnalytics()); + + result.current.identifyUser(42); + + expect(analyticsManager.setUserId).toHaveBeenCalledWith('42'); + expect(analyticsManager.setUserProperties).toHaveBeenCalledWith( + expect.objectContaining({ user_type: 'member' }), + ); + }); + + test('사용자 세션을 종료하면 분석 상태가 초기화된다', () => { + const { result } = renderHook(() => useAnalytics()); + + result.current.resetUser(); + + expect(analyticsManager.reset).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useAnalytics.ts b/src/hooks/useAnalytics.ts new file mode 100644 index 00000000..e72442eb --- /dev/null +++ b/src/hooks/useAnalytics.ts @@ -0,0 +1,42 @@ +import { useCallback } from 'react'; +import { analyticsManager } from '../util/analytics'; +import type { + AnalyticsEventMap, + AnalyticsEventName, + PageViewProperties, +} from '../util/analytics/types'; +import { DEFAULT_LANG } from '../util/languageRouting'; + +export default function useAnalytics() { + const trackEvent = useCallback( + ( + eventName: T, + properties: AnalyticsEventMap[T], + ) => { + analyticsManager.trackEvent(eventName, properties); + }, + [], + ); + + const trackPageView = useCallback((properties: PageViewProperties) => { + analyticsManager.trackPageView(properties); + }, []); + + const identifyUser = useCallback((memberId: number) => { + analyticsManager.setUserId(String(memberId)); + analyticsManager.setUserProperties({ + user_type: 'member', + language: document.documentElement.lang || DEFAULT_LANG, + }); + }, []); + + const resetUser = useCallback(() => { + analyticsManager.reset(); + }, []); + + const flush = useCallback(() => { + analyticsManager.flush(); + }, []); + + return { trackEvent, trackPageView, identifyUser, resetUser, flush }; +} diff --git a/src/hooks/usePageTracking.test.tsx b/src/hooks/usePageTracking.test.tsx new file mode 100644 index 00000000..f249f405 --- /dev/null +++ b/src/hooks/usePageTracking.test.tsx @@ -0,0 +1,87 @@ +import { renderHook } from '@testing-library/react'; +import { + RouterProvider, + createMemoryRouter, + Outlet, +} from 'react-router-dom'; +import type { PropsWithChildren, ReactNode } from 'react'; +import usePageTracking from './usePageTracking'; +import { analyticsManager } from '../util/analytics'; + +vi.mock('../util/analytics', () => ({ + analyticsManager: { + trackPageView: vi.fn(), + trackEvent: vi.fn(), + flush: vi.fn(), + }, +})); + +function createWrapper(initialPath: string = '/home') { + return function Wrapper({ children }: PropsWithChildren) { + function Layout({ slot }: { slot: ReactNode }) { + return ( + <> + {slot} + + + ); + } + + const router = createMemoryRouter( + [ + { + path: '*', + element: , + }, + ], + { initialEntries: [initialPath] }, + ); + + return ; + }; +} + +describe('usePageTracking', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('페이지에 진입하면 page_view가 한 번 기록된다', () => { + renderHook(() => usePageTracking(), { wrapper: createWrapper() }); + expect(analyticsManager.trackPageView).toHaveBeenCalledTimes(1); + }); + + test('페이지를 떠나면 머문 시간과 함께 page_leave가 기록된다', () => { + const { unmount } = renderHook(() => usePageTracking(), { + wrapper: createWrapper(), + }); + + vi.advanceTimersByTime(500); + unmount(); + + expect(analyticsManager.trackEvent).toHaveBeenCalledWith( + 'page_leave', + expect.objectContaining({ + duration_ms: expect.any(Number), + }), + ); + }); + + test('page_leave의 duration_ms는 실제로 머문 시간을 반영한다', () => { + const { unmount } = renderHook(() => usePageTracking(), { + wrapper: createWrapper(), + }); + + vi.advanceTimersByTime(100); + unmount(); + + const call = vi.mocked(analyticsManager.trackEvent).mock.calls[0]; + const properties = call[1] as { duration_ms: number }; + expect(properties.duration_ms).toBeGreaterThanOrEqual(100); + }); +}); diff --git a/src/hooks/usePageTracking.ts b/src/hooks/usePageTracking.ts new file mode 100644 index 00000000..0e14294e --- /dev/null +++ b/src/hooks/usePageTracking.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef } from 'react'; +import { useLocation, useMatches } from 'react-router-dom'; +import { analyticsManager } from '../util/analytics'; + +function normalizePath(pathname: string, params: Readonly>): string { + let normalized = pathname; + // 긴 값부터 치환해야 짧은 값이 긴 값의 일부를 덮어쓰는 것을 방지 + const entries = Object.entries(params) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .sort(([, a], [, b]) => b.length - a.length); + for (const [key, value] of entries) { + normalized = normalized.split(`/${value}`).join(`/:${key}`); + } + return normalized; +} + +export default function usePageTracking() { + const location = useLocation(); + const matches = useMatches(); + const entryTimeRef = useRef(Date.now()); + const previousPathRef = useRef(''); + const hasTrackedLeaveRef = useRef(false); + + const lastMatch = matches[matches.length - 1]; + const normalizedPath = normalizePath(location.pathname, lastMatch?.params ?? {}); + const normalizedPathRef = useRef(normalizedPath); + normalizedPathRef.current = normalizedPath; + + useEffect(() => { + const currentPath = normalizedPathRef.current; + + // 새 페이지 진입 시 가드 초기화 + hasTrackedLeaveRef.current = false; + + // 새 페이지 page_view 발화 + analyticsManager.trackPageView({ + page_title: document.title, + previous_page_path: previousPathRef.current, + referrer: document.referrer, + }); + + // 진입 시각 및 이전 경로 갱신 + entryTimeRef.current = Date.now(); + previousPathRef.current = currentPath; + + // 언마운트 또는 경로 변경 시 page_leave 발화 (1회만) + return () => { + if (hasTrackedLeaveRef.current) return; + hasTrackedLeaveRef.current = true; + const duration = Date.now() - entryTimeRef.current; + analyticsManager.trackEvent('page_leave', { + page_title: document.title, + page_path: previousPathRef.current, + duration_ms: duration, + }); + }; + }, [location.pathname]); + + // pagehide/beforeunload 시 마지막 페이지 page_leave + flush + useEffect(() => { + function handlePageHide() { + if (hasTrackedLeaveRef.current) return; + hasTrackedLeaveRef.current = true; + const duration = Date.now() - entryTimeRef.current; + analyticsManager.trackEvent('page_leave', { + page_title: document.title, + page_path: previousPathRef.current, + duration_ms: duration, + }); + analyticsManager.flush(); + } + + window.addEventListener('pagehide', handlePageHide); + window.addEventListener('beforeunload', handlePageHide); + + return () => { + window.removeEventListener('pagehide', handlePageHide); + window.removeEventListener('beforeunload', handlePageHide); + }; + }, []); +} diff --git a/src/main.tsx b/src/main.tsx index 548dd07c..ead0ecf0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,7 +7,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import router from './routes/routes.tsx'; import './index.css'; import './i18n'; -import { setupGoogleAnalytics } from './util/setupGoogleAnalytics.tsx'; +import { setupAnalytics, analyticsManager } from './util/analytics'; +import { + getAccessToken, + getMemberId, + removeMemberId, +} from './util/accessToken'; +import { DEFAULT_LANG } from './util/languageRouting'; +import i18n from './i18n'; // Functions that calls msw mocking worker if (import.meta.env.VITE_MOCK_API === 'true') { @@ -42,7 +49,28 @@ if (import.meta.env.VITE_MOCK_API === 'true') { // Function that initializes main React app function initializeApp() { - setupGoogleAnalytics(); + setupAnalytics(); + + // memberId 복원: accessToken + memberId 모두 존재 시 identity 설정 + const memberId = getMemberId(); + if (getAccessToken() && memberId) { + analyticsManager.setUserId(memberId); + analyticsManager.setUserProperties({ + user_type: 'member', + language: document.documentElement.lang || DEFAULT_LANG, + }); + } else if (memberId) { + // accessToken 없이 memberId만 있으면 비회원 처리 + removeMemberId(); + } + + // 언어 변경 시 user property 업데이트 + i18n.on('languageChanged', (lng: string) => { + analyticsManager.setUserProperties({ + user_type: getAccessToken() ? 'member' : 'guest', + language: lng, + }); + }); // Call queryClient for TanStack Query const queryClient = new QueryClient({ diff --git a/src/page/DebateEndPage/DebateEndPage.tsx b/src/page/DebateEndPage/DebateEndPage.tsx index 27d7109b..42c533eb 100644 --- a/src/page/DebateEndPage/DebateEndPage.tsx +++ b/src/page/DebateEndPage/DebateEndPage.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; - +import useAnalytics from '../../hooks/useAnalytics'; import clapImage from '../../assets/debateEnd/clap.png'; import feedbackTimerImage from '../../assets/debateEnd/feedback_timer.png'; import voteStampImage from '../../assets/debateEnd/vote_stamp.png'; @@ -12,19 +12,28 @@ import { DEFAULT_LANG, isSupportedLang, } from '../../util/languageRouting'; +import { useGetDebateTableData } from '../../hooks/query/useGetDebateTableData'; +// 토론 종료 후 피드백 타이머와 투표 진행 액션을 제공하는 페이지다. export default function DebateEndPage() { const { t, i18n } = useTranslation(); const { id } = useParams(); const tableId = Number(id); + const isTableIdValid = !!id && !Number.isNaN(tableId); const navigate = useNavigate(); + const { trackEvent } = useAnalytics(); + useGetDebateTableData(tableId, isTableIdValid); const currentLang = i18n.resolvedLanguage ?? i18n.language; const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + // 피드백 타이머 진입 시 feedback_timer_started 이벤트를 기록한다. const handleFeedbackClick = () => { + trackEvent('feedback_timer_started', { table_id: tableId }); navigate(buildLangPath(`/table/customize/${tableId}/end/feedback`, lang)); }; + // 투표 생성 직후 poll_created 이벤트를 기록하고 투표 화면으로 이동한다. const handleVoteClick = (pollId: number) => { + trackEvent('poll_created', { table_id: tableId, poll_id: pollId }); navigate( buildLangPath(`/table/customize/${tableId}/end/vote/${pollId}`, lang), ); @@ -37,7 +46,7 @@ export default function DebateEndPage() { }; // 테이블 ID 검증 - if (!id || isNaN(tableId)) { + if (!isTableIdValid) { throw new Error(t('테이블 ID가 올바르지 않습니다.')); } diff --git a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx index fc095ffc..7010fcff 100644 --- a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx +++ b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx @@ -8,14 +8,16 @@ import VoteDetailResult from './components/VoteDetailResult'; import { useGetPollInfo } from '../../hooks/query/useGetPollInfo'; import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import { TeamKey } from '../../type/type'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import DialogModal from '../../components/DialogModal/DialogModal'; import { buildLangPath, DEFAULT_LANG, isSupportedLang, } from '../../util/languageRouting'; +import useAnalytics from '../../hooks/useAnalytics'; +// 투표 결과를 불러오고 종료 화면 복귀 흐름을 제어하는 페이지다. export default function DebateVoteResultPage() { const { t, i18n } = useTranslation(); // 매개변수 검증 @@ -26,6 +28,8 @@ export default function DebateVoteResultPage() { const isTableIdValid = !!rawTableId && !Number.isNaN(tableId); const isArgsValid = isPollIdValid && isTableIdValid; + const { trackEvent } = useAnalytics(); + const hasTrackedPollResultRef = useRef(false); const [isConfirmed, setIsConfirmed] = useState(false); const navigate = useNavigate(); const currentLang = i18n.resolvedLanguage ?? i18n.language; @@ -40,15 +44,21 @@ export default function DebateVoteResultPage() { refetch, isRefetchError, } = useGetPollInfo(pollId, { enabled: isArgsValid }); + // 세부 결과 확인 후 홈으로 이동한다. const handleGoHome = () => { navigate(rootPath); }; + // 뒤로가기 시에도 종료 화면으로 복귀하도록 경로를 고정한다. const handleGoToEndPage = useCallback(() => { navigate(buildLangPath(`/table/customize/${tableId}/end`, lang), { replace: true, }); }, [lang, navigate, tableId]); + const isLoading = isFetching || isRefetching; + const isError = isFetchError || isRefetchError; + + // 브라우저 뒤로가기 입력을 종료 화면 복귀 동작으로 치환한다. useEffect(() => { if (!isArgsValid) return; @@ -56,12 +66,18 @@ export default function DebateVoteResultPage() { return () => window.removeEventListener('popstate', handleGoToEndPage); }, [handleGoToEndPage, isArgsValid]); - const isLoading = isFetching || isRefetching; - const isError = isFetchError || isRefetchError; + // 결과 데이터 로드 성공 후 poll_result_viewed 이벤트를 1회만 기록한다. + useEffect(() => { + if (isArgsValid && data && !isError && !hasTrackedPollResultRef.current) { + hasTrackedPollResultRef.current = true; + trackEvent('poll_result_viewed', { poll_id: pollId }); + } + }, [data, isArgsValid, isError, pollId, trackEvent]); const { openModal, ModalWrapper, closeModal } = useModal({ onClose: () => setIsConfirmed(false), }); + // 득표 수를 비교해 승리 팀 또는 무승부 상태를 계산한다. const getWinner = (result: { prosTeamName: string; consTeamName: string; diff --git a/src/page/LandingPage/components/TemplateCard.tsx b/src/page/LandingPage/components/TemplateCard.tsx index c6227ead..cb80c888 100644 --- a/src/page/LandingPage/components/TemplateCard.tsx +++ b/src/page/LandingPage/components/TemplateCard.tsx @@ -2,17 +2,20 @@ import { useTranslation } from 'react-i18next'; import { Organization } from '../../../type/type'; import clsx from 'clsx'; import { createTableShareUrlFromEncodedData } from '../../../util/arrayEncoding'; +import useAnalytics from '../../../hooks/useAnalytics'; interface TemplateCardProps { organization: Organization; className?: string; // 카드의 추가 className이 필요하면 사용 } +// 단체별 템플릿 링크와 선택 액션을 보여주는 카드다. export default function TemplateCard({ organization, className = '', }: TemplateCardProps) { const { t } = useTranslation(); + const { trackEvent } = useAnalytics(); const logoUrl = import.meta.env.VITE_API_BASE_URL + organization.iconPath; return ( @@ -54,10 +57,18 @@ export default function TemplateCard({ {template.name} + {/* 템플릿 선택 시 template_selected 이벤트를 기록하고 공유 링크로 이동한다. */} + trackEvent('template_selected', { + organization_name: organization.organization, + template_name: template.name, + template_label: `${organization.organization} - ${template.name}`, + }) + } > {t('토론하기')} diff --git a/src/page/LandingPage/hooks/useLandingPageHandlers.ts b/src/page/LandingPage/hooks/useLandingPageHandlers.ts index 576fdb80..a5524ee9 100644 --- a/src/page/LandingPage/hooks/useLandingPageHandlers.ts +++ b/src/page/LandingPage/hooks/useLandingPageHandlers.ts @@ -11,9 +11,12 @@ import { DEFAULT_LANG, isSupportedLang, } from '../../../util/languageRouting'; +import useAnalytics from '../../../hooks/useAnalytics'; +import { setLoginTrigger } from '../../../util/analytics/loginTrigger'; +// 랜딩 페이지의 CTA와 로그인 진입 경로 추적을 한곳에서 관리한다. const useLandingPageHandlers = () => { - // Prepare dependencies + // 라우팅, 인증, 분석에 필요한 의존성을 준비한다. const navigate = useNavigate(); const { i18n } = useTranslation(); const currentLang = i18n.resolvedLanguage ?? i18n.language; @@ -21,8 +24,9 @@ const useLandingPageHandlers = () => { const homePath = buildLangPath('/home', lang); const rootPath = buildLangPath('/', lang); const { mutate: logoutMutate } = useLogout(() => navigate(homePath)); + const { trackEvent } = useAnalytics(); - // Declare functions that represent business logics + // 비회원 체험용 샘플 테이블 공유 링크로 바로 이동한다. const handleStartWithoutLogin = useCallback(() => { // window.location.href = LANDING_URLS.START_WITHOUT_LOGIN_URL; window.location.href = createTableShareUrlFromTable( @@ -30,23 +34,42 @@ const useLandingPageHandlers = () => { SAMPLE_TABLE_DATA, ); }, []); + // 테이블 섹션 CTA에서 로그인 시작 시 login_started 이벤트를 기록한다. const handleTableSectionLoginButtonClick = useCallback(() => { if (!isLoggedIn()) { + trackEvent('login_started', { + trigger_page: homePath, + trigger_context: 'landing_table_section', + }); + setLoginTrigger({ + trigger_page: homePath, + trigger_context: 'landing_table_section', + }); oAuthLogin(); } else { navigate(rootPath); } - }, [navigate, rootPath]); + }, [homePath, navigate, rootPath, trackEvent]); + // 로그인된 사용자를 메인 화면으로 이동시킨다. const handleDashboardButtonClick = useCallback(() => { navigate(rootPath); }, [navigate, rootPath]); + // 헤더 로그인 버튼 진입 시 login_started 이벤트를 기록하거나 로그아웃을 수행한다. const handleHeaderLoginButtonClick = useCallback(() => { if (!isLoggedIn()) { + trackEvent('login_started', { + trigger_page: homePath, + trigger_context: 'landing_header', + }); + setLoginTrigger({ + trigger_page: homePath, + trigger_context: 'landing_header', + }); oAuthLogin(); } else { logoutMutate(); } - }, [logoutMutate]); + }, [homePath, logoutMutate, trackEvent]); return { handleStartWithoutLogin, diff --git a/src/page/OAuthPage/OAuth.tsx b/src/page/OAuthPage/OAuth.tsx index 52f73a88..5e5027ff 100644 --- a/src/page/OAuthPage/OAuth.tsx +++ b/src/page/OAuthPage/OAuth.tsx @@ -11,7 +11,10 @@ import { DEFAULT_LANG, isSupportedLang, } from '../../util/languageRouting'; +import useAnalytics from '../../hooks/useAnalytics'; +import { consumeLoginTrigger } from '../../util/analytics/loginTrigger'; +// OAuth 콜백을 처리하고 로그인 완료 후 적절한 화면으로 이동시킨다. export default function OAuth() { const { i18n } = useTranslation(); const navigate = useNavigate(); @@ -20,7 +23,17 @@ export default function OAuth() { const currentLang = i18n.resolvedLanguage ?? i18n.language; const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; - const { mutate } = usePostUser(() => { + const { trackEvent } = useAnalytics(); + + // 로그인 완료 시 login_completed 이벤트를 기록하고 게스트 플로우 여부에 따라 이동한다. + const { mutate } = usePostUser((data) => { + const trigger = consumeLoginTrigger(); + trackEvent('login_completed', { + trigger_page: trigger?.trigger_page ?? 'unknown', + trigger_context: trigger?.trigger_context ?? 'unknown', + member_id: data.id, + }); + const keepGuestTable = sessionStorage.getItem('keepGuestTable'); if (keepGuestTable === 'false') { @@ -36,6 +49,7 @@ export default function OAuth() { } }); + // OAuth 인가 코드를 한 번만 소비해 로그인 요청을 보낸다. useEffect(() => { if (hasProcessedLogin.current === true) { return; diff --git a/src/page/TableOverviewPage/TableOverviewPage.tsx b/src/page/TableOverviewPage/TableOverviewPage.tsx index 3bf9a1ea..23a0c008 100644 --- a/src/page/TableOverviewPage/TableOverviewPage.tsx +++ b/src/page/TableOverviewPage/TableOverviewPage.tsx @@ -25,7 +25,9 @@ import { DEFAULT_LANG, isSupportedLang, } from '../../util/languageRouting'; +import useAnalytics from '../../hooks/useAnalytics'; +// 토론 개요를 보여주고 공유, 수정, 시작 액션을 제공하는 페이지다. export default function TableOverviewPage() { const { t, i18n } = useTranslation(); const { id } = useParams(); @@ -33,19 +35,22 @@ export default function TableOverviewPage() { const navigate = useNavigate(); const { openModal, closeModal, ModalWrapper } = useModal(); const [modalCoinState, setModalCoinState] = useState('initial'); + const { trackEvent } = useAnalytics(); const currentLang = i18n.resolvedLanguage ?? i18n.language; const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + // 팀 선정 모달을 초기 상태로 열어준다. const handleOpenModal = () => { setModalCoinState('initial'); openModal(); }; + // 동전 애니메이션 단계 변화를 모달 상태에 반영한다. const handleCoinStateChange = useCallback((newState: CoinState) => { setModalCoinState(newState); }, []); - // Only uses hooks related with customize due to the removal of parliamentary + // 커스터마이즈 테이블 정보만 조회하도록 관련 훅만 사용한다. const { data, isLoading: isFetching, @@ -58,7 +63,7 @@ export default function TableOverviewPage() { navigate(buildLangPath(`/table/customize/${tableId}`, lang)); }); - // Hook for sharing tables + // 공유 모달 열기와 링크 생성 로직을 함께 제공한다. const { openShareModal, TableShareModal } = useTableShare(tableId); const isLoading = isFetching || isRefetching; const isError = isFetchError || isRefetchError; @@ -184,6 +189,10 @@ export default function TableOverviewPage() { })} disabled={isLoading} onClick={() => { + // 공유 버튼 클릭 시 table_shared 이벤트를 기록한다. + trackEvent('table_shared', { + table_id: isGuestFlow() ? 'guest' : tableId, + }); openShareModal(); }} > diff --git a/src/page/TableSharingPage/TableSharingPage.tsx b/src/page/TableSharingPage/TableSharingPage.tsx index 356d7b7e..66502e9f 100644 --- a/src/page/TableSharingPage/TableSharingPage.tsx +++ b/src/page/TableSharingPage/TableSharingPage.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useModal } from '../../hooks/useModal'; import LoggedInStoreDBModal from './components/LoggedInStoreDBModal'; @@ -19,7 +19,10 @@ import { DEFAULT_LANG, isSupportedLang, } from '../../util/languageRouting'; +import useAnalytics from '../../hooks/useAnalytics'; +import { setTemplateOrigin } from '../../util/analytics/templateOrigin'; +// 공유 URL의 data 파라미터를 안전하게 디코딩하고 실패 시 null을 반환한다. function getDecodedDataOrNull( encodedData: string | null, ): DebateTableData | null { @@ -52,6 +55,7 @@ function getDecodedDataOrNull( export default function TableSharingPage() { const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const { trackEvent } = useAnalytics(); const currentLang = i18n.resolvedLanguage ?? i18n.language; const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; const { openModal, closeModal, ModalWrapper } = useModal({ @@ -59,8 +63,34 @@ export default function TableSharingPage() { }); const [searchParams] = useSearchParams(); const encodedData = searchParams.get('data'); - const decodedData = getDecodedDataOrNull(encodedData); + // encodedData가 변경될 때만 디코딩을 재실행해 effect 의존성을 안정화한다. + const decodedData = useMemo(() => getDecodedDataOrNull(encodedData), [encodedData]); + const source = searchParams.get('source'); + const isTemplateEntry = source === 'template'; + // 공유 링크 유입 추적: encodedData/isTemplateEntry에만 의존해 1회만 발화한다. + useEffect(() => { + if (encodedData && !isTemplateEntry) { + trackEvent('share_link_entered', { referrer: document.referrer }); + } + }, [encodedData, isTemplateEntry, trackEvent]); + + // 템플릿 유입 정보는 decodedData가 유효할 때만 저장한다. + useEffect(() => { + if (isTemplateEntry && decodedData) { + const org = searchParams.get('org'); + const tmpl = searchParams.get('tmpl'); + if (org && tmpl) { + setTemplateOrigin({ + organization_name: org, + template_name: tmpl, + template_label: `${org} - ${tmpl}`, + }); + } + } + }, [decodedData, isTemplateEntry, searchParams]); + + // 로그인 상태와 URL 형태에 따라 저장 모달, 게스트 복사, 즉시 저장 플로우를 분기한다. useEffect(() => { if (isLoggedIn()) { if (isGuestFlow() && encodedData === null) { @@ -131,10 +161,11 @@ export default function TableSharingPage() { - {/* On this case, we have to specify the data source */} + {/* 로그인 사용자는 저장 여부에 따라 계정 저장 또는 게스트 이어하기를 선택한다. */} {decodedData && ( { + // 공유받은 테이블을 계정에 저장한 뒤 개요 화면으로 이동한다. apiDebateTableRepository.addTable(decodedData).then( (value) => { closeModal(); @@ -150,6 +181,7 @@ export default function TableSharingPage() { ); }} onContinue={() => { + // 공유받은 테이블을 세션에만 저장하고 게스트 플로우로 이어간다. sessionDebateTableRepository.addTable(decodedData).then( () => { closeModal(); diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index d5a671cd..9665e20d 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; @@ -5,6 +6,7 @@ import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; import { useTimerPageState } from './hooks/useTimerPageState'; import { useTimerHotkey } from './hooks/useTimerHotkey'; +import useDebateTracking from './hooks/useDebateTracking'; import RoundControlRow from './components/RoundControlRow'; import TimerView from './components/TimerView'; import { FirstUseToolTipModal } from './components/FirstUseToolTipModal'; @@ -15,6 +17,9 @@ import DTHelp from '../../components/icons/Help'; import clsx from 'clsx'; import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator'; +import { isGuestFlow } from '../../util/sessionStorage'; +import useAnalytics from '../../hooks/useAnalytics'; +import { consumeTemplateOrigin } from '../../util/analytics/templateOrigin'; import { RiFullscreenFill, RiFullscreenExitFill, @@ -23,6 +28,7 @@ import { import DTVolume from '../../components/icons/Volume'; import VolumeBar from '../../components/VolumeBar/VolumeBar'; +// 토론 타이머 실행, 라운드 이동, 종료 흐름을 관리하는 메인 페이지다. export default function TimerPage() { const { t } = useTranslation(); const pathParams = useParams(); @@ -37,8 +43,13 @@ export default function TimerPage() { } = useTimerPageModal(tableId); const state = useTimerPageState(tableId); + // timer_started, debate_completed, debate_abandoned 관련 추적 상태를 관리한다. + const { trackTimerStarted, trackDebateCompleted, updateProgress } = + useDebateTracking(); + const { trackEvent } = useAnalytics(); useTimerHotkey(state); + const timerStartedRef = useRef(false); const isMuted = state.volume === 0; const { data, @@ -58,7 +69,36 @@ export default function TimerPage() { volumeRef, } = state; - // If error, print error message and let user be able to retry + // timer_started 이벤트 발화 (데이터 로드 후 1회) + useEffect(() => { + if (data && !timerStartedRef.current) { + timerStartedRef.current = true; + trackTimerStarted({ + table_id: isGuestFlow() ? 'guest' : tableId, + total_rounds: data.table.length, + }); + + // 템플릿 진입인 경우 template_used 이벤트 발화 + const origin = consumeTemplateOrigin(); + if (origin) { + trackEvent('template_used', { + organization_name: origin.organization_name, + template_name: origin.template_name, + template_label: `${origin.organization_name} - ${origin.template_name}`, + table_id: isGuestFlow() ? 'guest' : tableId, + }); + } + } + }, [data, tableId, trackTimerStarted, trackEvent]); + + // 라운드 진행 상황 업데이트 + useEffect(() => { + if (data) { + updateProgress(index + 1, data.table.length); + } + }, [index, data, updateProgress]); + + // 오류가 발생하면 재시도 가능한 에러 화면을 노출한다. if (isError) { return ( @@ -69,7 +109,7 @@ export default function TimerPage() { ); } - // If no error or on loading, print contents + // 로딩 또는 정상 상태에 맞는 타이머 화면을 렌더링한다. return ( <> @@ -174,6 +214,12 @@ export default function TimerPage() { setFullscreen(false); } + // debate_completed 이벤트를 먼저 기록해 cleanup의 debate_abandoned를 막는다. + trackDebateCompleted({ + table_id: isGuestFlow() ? 'guest' : tableId, + total_rounds: data.table.length, + }); + openLoginAndStoreModalOrGoToDebateEndPage(); }} className="absolute bottom-[66px] left-1/2 -translate-x-1/2" @@ -184,13 +230,13 @@ export default function TimerPage() { - {/* Modal for users who have not used this timer */} + {/* 첫 사용자를 위한 타이머 사용 안내 모달이다. */} - {/* Modal that asks users whether they want to store the timetable in their account */} + {/* 토론 종료 후 로그인 저장 여부를 묻는 모달이다. */} void; } +// 토론 종료 후 로그인 저장 여부를 묻는 확인 모달이다. export function LoginAndStoreModal({ Wrapper, onClose, }: LoginAndStoreModalProps) { const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const { trackEvent } = useAnalytics(); const currentLang = i18n.resolvedLanguage ?? i18n.language; const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; @@ -32,6 +36,7 @@ export function LoginAndStoreModal({ left={{ text: t('아니오'), onClick: () => { + // 저장 없이 게스트 개요 화면으로 바로 이동한다. onClose(); navigate(buildLangPath('/overview/customize/guest', lang)); }, @@ -40,6 +45,16 @@ export function LoginAndStoreModal({ text: t('네'), onClick: () => { onClose(); + // 모달에서 로그인 시작 시 login_started 이벤트를 기록한다. + trackEvent('login_started', { + trigger_page: window.location.pathname, + trigger_context: 'timer_modal', + }); + // 로그인 완료 후 복귀 맥락을 복원할 수 있도록 트리거를 저장한다. + setLoginTrigger({ + trigger_page: window.location.pathname, + trigger_context: 'timer_modal', + }); oAuthLogin(); }, isBold: true, diff --git a/src/page/TimerPage/hooks/useDebateTracking.test.ts b/src/page/TimerPage/hooks/useDebateTracking.test.ts new file mode 100644 index 00000000..1d9a5f1c --- /dev/null +++ b/src/page/TimerPage/hooks/useDebateTracking.test.ts @@ -0,0 +1,79 @@ +import { renderHook, act } from '@testing-library/react'; +import useDebateTracking from './useDebateTracking'; +import { analyticsManager } from '../../../util/analytics'; + +vi.mock('../../../util/analytics', () => ({ + analyticsManager: { + trackEvent: vi.fn(), + flush: vi.fn(), + }, +})); + +describe('useDebateTracking', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('trackTimerStarted 호출 시 timer_started 이벤트가 발생한다', () => { + const { result } = renderHook(() => useDebateTracking()); + act(() => { + result.current.trackTimerStarted({ table_id: 1, total_rounds: 5 }); + }); + expect(analyticsManager.trackEvent).toHaveBeenCalledWith( + 'timer_started', + expect.objectContaining({ table_id: 1, total_rounds: 5 }), + ); + }); + + test('trackDebateCompleted 호출 시 debate_completed 이벤트가 발생한다', () => { + const { result } = renderHook(() => useDebateTracking()); + act(() => { + result.current.trackDebateCompleted({ table_id: 1, total_rounds: 5 }); + }); + expect(analyticsManager.trackEvent).toHaveBeenCalledWith( + 'debate_completed', + expect.objectContaining({ table_id: 1, total_rounds: 5 }), + ); + }); + + test('토론 시작 후 언마운트 시 debate_abandoned 이벤트가 발생한다', () => { + const { result, unmount } = renderHook(() => useDebateTracking()); + act(() => { + result.current.trackTimerStarted({ table_id: 1, total_rounds: 5 }); + }); + vi.mocked(analyticsManager.trackEvent).mockClear(); + + act(() => { + result.current.updateProgress(2, 5); + }); + unmount(); + + expect(analyticsManager.trackEvent).toHaveBeenCalledWith( + 'debate_abandoned', + expect.objectContaining({ + table_id: 1, + current_round: 2, + total_rounds: 5, + abandon_type: 'navigation', + }), + ); + }); + + test('토론 완료 후 언마운트 시 debate_abandoned가 발생하지 않는다', () => { + const { result, unmount } = renderHook(() => useDebateTracking()); + act(() => { + result.current.trackTimerStarted({ table_id: 1, total_rounds: 5 }); + result.current.trackDebateCompleted({ table_id: 1, total_rounds: 5 }); + }); + vi.mocked(analyticsManager.trackEvent).mockClear(); + + unmount(); + + const abandonCalls = vi + .mocked(analyticsManager.trackEvent) + .mock.calls.filter( + (call: [string, ...unknown[]]) => call[0] === 'debate_abandoned', + ); + expect(abandonCalls).toHaveLength(0); + }); +}); diff --git a/src/page/TimerPage/hooks/useDebateTracking.ts b/src/page/TimerPage/hooks/useDebateTracking.ts new file mode 100644 index 00000000..f1b58511 --- /dev/null +++ b/src/page/TimerPage/hooks/useDebateTracking.ts @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { analyticsManager } from '../../../util/analytics'; +import type { + DebateAbandonedProperties, + DebateCompletedProperties, + TimerStartedProperties, +} from '../../../util/analytics/types'; + +// 토론 시작, 완료, 이탈 이벤트를 일관되게 기록하기 위한 추적 훅이다. +export default function useDebateTracking() { + const isDebateActiveRef = useRef(false); + const isCompletedRef = useRef(false); + const debateInfoRef = useRef<{ + table_id: number | 'guest'; + current_round: number; + total_rounds: number; + } | null>(null); + + // 타이머 시작 시 timer_started 이벤트와 현재 토론 메타데이터를 기록한다. + const trackTimerStarted = useCallback( + (properties: TimerStartedProperties) => { + analyticsManager.trackEvent('timer_started', properties); + isDebateActiveRef.current = true; + isCompletedRef.current = false; + debateInfoRef.current = { + table_id: properties.table_id, + current_round: 1, + total_rounds: properties.total_rounds, + }; + }, + [], + ); + + // 토론 종료 시 debate_completed 이벤트를 기록하고 활성 상태를 정리한다. + const trackDebateCompleted = useCallback( + (properties: DebateCompletedProperties) => { + analyticsManager.trackEvent('debate_completed', properties); + isDebateActiveRef.current = false; + isCompletedRef.current = true; + }, + [], + ); + + // 현재 라운드와 전체 라운드 수를 최신 상태로 유지한다. + const updateProgress = useCallback( + (currentRound: number, totalRounds: number) => { + if (debateInfoRef.current) { + debateInfoRef.current.current_round = currentRound; + debateInfoRef.current.total_rounds = totalRounds; + } + }, + [], + ); + + // 비정상 이탈이 감지되면 debate_abandoned 이벤트를 한 번만 기록한다. + const sendAbandonEvent = useCallback( + (abandonType: DebateAbandonedProperties['abandon_type']) => { + if ( + isDebateActiveRef.current && + !isCompletedRef.current && + debateInfoRef.current + ) { + analyticsManager.trackEvent('debate_abandoned', { + table_id: debateInfoRef.current.table_id, + current_round: debateInfoRef.current.current_round, + total_rounds: debateInfoRef.current.total_rounds, + abandon_type: abandonType, + }); + analyticsManager.flush(); + isDebateActiveRef.current = false; + } + }, + [], + ); + + // 새로고침, 탭 비가시화, SPA 이탈 시 abandon 이벤트를 보낼 리스너를 등록한다. + useEffect(() => { + let abandonTimer: ReturnType | null = null; + + function handleBeforeUnload() { + sendAbandonEvent('unload'); + } + + function handleVisibilityChange() { + if (document.visibilityState === 'hidden') { + // 짧은 탭 전환을 abandon으로 오기록하지 않도록 10초 딜레이 후 발화한다. + abandonTimer = setTimeout(() => { + sendAbandonEvent('visibility'); + }, 10000); + } else { + // 탭으로 돌아오면 예약된 abandon을 취소한다. + if (abandonTimer !== null) { + clearTimeout(abandonTimer); + abandonTimer = null; + } + } + } + + window.addEventListener('beforeunload', handleBeforeUnload); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + document.removeEventListener('visibilitychange', handleVisibilityChange); + if (abandonTimer !== null) { + clearTimeout(abandonTimer); + } + // SPA navigation 이탈 + sendAbandonEvent('navigation'); + }; + }, [sendAbandonEvent]); + + return { + trackTimerStarted, + trackDebateCompleted, + updateProgress, + }; +} diff --git a/src/page/VoteParticipationPage/VoteParticipationPage.tsx b/src/page/VoteParticipationPage/VoteParticipationPage.tsx index 5341ecde..b935221a 100644 --- a/src/page/VoteParticipationPage/VoteParticipationPage.tsx +++ b/src/page/VoteParticipationPage/VoteParticipationPage.tsx @@ -17,7 +17,9 @@ import { DEFAULT_LANG, isSupportedLang, } from '../../util/languageRouting'; +import useAnalytics from '../../hooks/useAnalytics'; +// 투표 참여자의 입력을 받고 제출을 처리하는 페이지다. export default function VoteParticipationPage() { const { t, i18n } = useTranslation(); const { id: pollIdParam } = useParams(); @@ -28,6 +30,7 @@ export default function VoteParticipationPage() { const pollId = pollIdParam ? Number(pollIdParam) : NaN; const isValidPollId = !!pollIdParam && !Number.isNaN(pollId); + const { trackEvent } = useAnalytics(); const [participantName, setParticipantName] = useState(''); const [selectedTeam, setSelectedTeam] = useState(null); @@ -48,8 +51,10 @@ export default function VoteParticipationPage() { navigate(buildLangPath('/vote/end', lang)), ); + // 유효한 입력이 모였을 때 poll_voted 이벤트를 기록하고 투표를 제출한다. const handleSubmit = () => { - if (isSubmitDisabled) return; + if (isSubmitDisabled || !selectedTeam) return; + trackEvent('poll_voted', { poll_id: pollId, team: selectedTeam }); mutate({ pollId: pollId, voterInfo: { diff --git a/src/routes/LanguageWrapper.tsx b/src/routes/LanguageWrapper.tsx index ec14426d..987f6b33 100644 --- a/src/routes/LanguageWrapper.tsx +++ b/src/routes/LanguageWrapper.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'; import i18n from '../i18n'; +import usePageTracking from '../hooks/usePageTracking'; import { DEFAULT_LANG, buildLangPath, @@ -14,6 +15,8 @@ export default function LanguageWrapper() { const location = useLocation(); const navigate = useNavigate(); + usePageTracking(); + useEffect(() => { const selectedLang = getSelectedLangFromRoute(lang, location.pathname); const currentLang = i18n.resolvedLanguage ?? i18n.language; diff --git a/src/routes/ProtectedRoute.tsx b/src/routes/ProtectedRoute.tsx index e2372309..1ce9131b 100644 --- a/src/routes/ProtectedRoute.tsx +++ b/src/routes/ProtectedRoute.tsx @@ -7,6 +7,7 @@ import { DEFAULT_LANG, isSupportedLang, } from '../util/languageRouting'; +import { setLoginTrigger } from '../util/analytics/loginTrigger'; export default function ProtectedRoute(props: PropsWithChildren) { const { children } = props; @@ -18,9 +19,13 @@ export default function ProtectedRoute(props: PropsWithChildren) { const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; const homePath = buildLangPath('/home', lang); - return isAuthenticated ? ( - children - ) : ( - - ); + if (!isAuthenticated) { + setLoginTrigger({ + trigger_page: location.pathname, + trigger_context: 'protected_route', + }); + return ; + } + + return children; } diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index d6c9a899..bd3f90b0 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -5,7 +5,6 @@ import TableCompositionPage from '../page/TableComposition/TableCompositionPage' import ErrorBoundaryWrapper from '../components/ErrorBoundary/ErrorBoundaryWrapper'; import ProtectedRoute from './ProtectedRoute'; import OAuth from '../page/OAuthPage/OAuth'; -import ReactGA from 'react-ga4'; import NotFoundPage from '../components/ErrorBoundary/NotFoundPage'; import BackActionHandler from '../components/BackActionHandler'; import TimerPage from '../page/TimerPage/TimerPage'; @@ -130,9 +129,4 @@ const router = createBrowserRouter( }, ); -// 라우트 변경 시 Google Analytics 이벤트 전송 -router.subscribe(({ location }) => { - ReactGA.send({ hitType: 'pageview', page: location.pathname }); -}); - export default router; diff --git a/src/util/accessToken.ts b/src/util/accessToken.ts index aa69c079..efa4e14f 100644 --- a/src/util/accessToken.ts +++ b/src/util/accessToken.ts @@ -13,3 +13,15 @@ export const removeAccessToken = (): void => { export const isLoggedIn = (): boolean => { return !!getAccessToken(); }; + +export const setMemberId = (id: number): void => { + localStorage.setItem('memberId', String(id)); +}; + +export const getMemberId = (): string | null => { + return localStorage.getItem('memberId'); +}; + +export const removeMemberId = (): void => { + localStorage.removeItem('memberId'); +}; diff --git a/src/util/analytics/analyticsManager.test.ts b/src/util/analytics/analyticsManager.test.ts new file mode 100644 index 00000000..fd4bc76d --- /dev/null +++ b/src/util/analytics/analyticsManager.test.ts @@ -0,0 +1,168 @@ +import { AnalyticsManager } from './analyticsManager'; +import type { AnalyticsProvider } from './types'; + +/** + * 매니저 테스트에 사용할 mock provider를 만든다. + * `name`은 provider 식별용 이름이며, 각 메서드가 mock 처리된 `AnalyticsProvider`를 반환한다. + */ +function createMockProvider(name = 'mock'): AnalyticsProvider { + return { + name, + init: vi.fn(), + trackPageView: vi.fn(), + trackEvent: vi.fn(), + setUserId: vi.fn(), + setUserProperties: vi.fn(), + reset: vi.fn(), + flush: vi.fn(), + }; +} + +describe('AnalyticsManager', () => { + let manager: AnalyticsManager; + + beforeEach(() => { + manager = new AnalyticsManager(); + }); + + test('provider를 등록하고 init 시 provider.init이 호출된다', () => { + const provider = createMockProvider(); + manager.addProvider(provider); + manager.init(); + expect(provider.init).toHaveBeenCalled(); + }); + + test('여러 provider에 이벤트를 팬아웃한다', () => { + const p1 = createMockProvider('p1'); + const p2 = createMockProvider('p2'); + manager.addProvider(p1); + manager.addProvider(p2); + + manager.trackEvent('template_selected', { + organization_name: 'org', + template_name: 'tmpl', + template_label: 'org - tmpl', + }); + + expect(p1.trackEvent).toHaveBeenCalled(); + expect(p2.trackEvent).toHaveBeenCalled(); + }); + + test('trackPageView를 모든 provider에 전달한다', () => { + const p1 = createMockProvider('p1'); + const p2 = createMockProvider('p2'); + manager.addProvider(p1); + manager.addProvider(p2); + + manager.trackPageView({ + page_title: 'Home', + previous_page_path: '', + referrer: '', + }); + + expect(p1.trackPageView).toHaveBeenCalled(); + expect(p2.trackPageView).toHaveBeenCalled(); + }); + + test('setUserId를 모든 provider에 전파한다', () => { + const p1 = createMockProvider('p1'); + const p2 = createMockProvider('p2'); + manager.addProvider(p1); + manager.addProvider(p2); + + manager.setUserId('123'); + + expect(p1.setUserId).toHaveBeenCalledWith('123'); + expect(p2.setUserId).toHaveBeenCalledWith('123'); + }); + + test('setUserProperties를 모든 provider에 전파한다', () => { + const p1 = createMockProvider('p1'); + manager.addProvider(p1); + + const props = { user_type: 'member' as const, language: 'ko' }; + manager.setUserProperties(props); + + expect(p1.setUserProperties).toHaveBeenCalledWith(props); + }); + + test('reset을 모든 provider에 전파한다', () => { + const p1 = createMockProvider('p1'); + manager.addProvider(p1); + + manager.reset(); + + expect(p1.reset).toHaveBeenCalled(); + }); + + test('flush를 모든 provider에 전파한다', () => { + const p1 = createMockProvider('p1'); + manager.addProvider(p1); + + manager.flush(); + + expect(p1.flush).toHaveBeenCalled(); + }); + + test('provider가 없어도 에러 없이 동작한다', () => { + expect(() => { + manager.trackEvent('template_selected', { + organization_name: 'org', + template_name: 'tmpl', + template_label: 'org - tmpl', + }); + }).not.toThrow(); + }); + + test('provider에서 에러 발생 시 다른 provider에 영향 없다', () => { + const errorProvider = createMockProvider('error'); + vi.mocked(errorProvider.trackEvent).mockImplementation(() => { + throw new Error('Provider error'); + }); + const normalProvider = createMockProvider('normal'); + + manager.addProvider(errorProvider); + manager.addProvider(normalProvider); + + manager.trackEvent('template_selected', { + organization_name: 'org', + template_name: 'tmpl', + template_label: 'org - tmpl', + }); + + expect(normalProvider.trackEvent).toHaveBeenCalled(); + }); + + test('trackEvent 시 글로벌 필드가 자동 합성된다', () => { + const provider = createMockProvider(); + manager.addProvider(provider); + + manager.trackEvent('template_selected', { + organization_name: 'org', + template_name: 'tmpl', + template_label: 'org - tmpl', + }); + + const calledWith = vi.mocked(provider.trackEvent).mock.calls[0]; + const properties = calledWith[1]; + expect(properties).toHaveProperty('user_type'); + expect(properties).toHaveProperty('language'); + expect(properties).toHaveProperty('page_path'); + }); + + test('trackPageView 시 글로벌 필드가 자동 합성된다', () => { + const provider = createMockProvider(); + manager.addProvider(provider); + + manager.trackPageView({ + page_title: 'Home', + previous_page_path: '', + referrer: '', + }); + + const calledWith = vi.mocked(provider.trackPageView).mock.calls[0][0]; + expect(calledWith).toHaveProperty('user_type'); + expect(calledWith).toHaveProperty('language'); + expect(calledWith).toHaveProperty('page_path'); + }); +}); diff --git a/src/util/analytics/analyticsManager.ts b/src/util/analytics/analyticsManager.ts new file mode 100644 index 00000000..429f0d27 --- /dev/null +++ b/src/util/analytics/analyticsManager.ts @@ -0,0 +1,115 @@ +import { isLoggedIn } from '../accessToken'; +import { DEFAULT_LANG } from '../languageRouting'; +import type { + AnalyticsEventMap, + AnalyticsEventName, + AnalyticsManagerInterface, + AnalyticsProvider, + AnalyticsUserProperties, + GlobalEventProperties, + PageViewProperties, +} from './types'; + +/** 공통 이벤트 필드를 합성해 등록된 모든 provider에 전파하는 애널리틱스 매니저다. */ +export class AnalyticsManager implements AnalyticsManagerInterface { + private providers: AnalyticsProvider[] = []; + + /** + * 이벤트를 전달할 provider를 등록한다. + * `provider`는 `AnalyticsProvider` 구현체이며, 반환값은 없다. + */ + addProvider(provider: AnalyticsProvider): void { + this.providers.push(provider); + } + + /** + * 등록된 모든 provider의 초기화를 순차적으로 실행한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + init(): void { + this.providers.forEach((p) => this.safeCall(p, () => p.init())); + } + + /** + * 페이지뷰 속성에 공통 필드를 합성해 모든 provider로 전달한다. + * `properties`는 페이지 제목, 이전 경로, referrer 등을 담으며 반환값은 없다. + */ + trackPageView(properties: PageViewProperties): void { + const enriched = { ...this.getGlobalProperties(), ...properties }; + this.providers.forEach((p) => + this.safeCall(p, () => p.trackPageView(enriched)), + ); + } + + /** + * 이벤트 속성에 공통 필드를 합성해 모든 provider로 전달한다. + * `eventName`은 이벤트 이름, `properties`는 해당 이벤트 전용 속성이며 반환값은 없다. + */ + trackEvent( + eventName: T, + properties: AnalyticsEventMap[T], + ): void { + const enriched = { ...this.getGlobalProperties(), ...properties }; + this.providers.forEach((p) => + this.safeCall(p, () => p.trackEvent(eventName, enriched)), + ); + } + + /** + * 등록된 모든 provider에 사용자 식별자를 설정한다. + * `userId`는 사용자 식별 문자열이며, 반환값은 없다. + */ + setUserId(userId: string): void { + this.providers.forEach((p) => this.safeCall(p, () => p.setUserId(userId))); + } + + /** + * 등록된 모든 provider에 사용자 속성을 설정한다. + * `properties`는 사용자 유형과 언어 정보를 담으며, 반환값은 없다. + */ + setUserProperties(properties: AnalyticsUserProperties): void { + this.providers.forEach((p) => + this.safeCall(p, () => p.setUserProperties(properties)), + ); + } + + /** + * 등록된 모든 provider의 사용자 상태를 초기화한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + reset(): void { + this.providers.forEach((p) => this.safeCall(p, () => p.reset())); + } + + /** + * 등록된 모든 provider의 대기 중인 이벤트 전송을 요청한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + flush(): void { + this.providers.forEach((p) => this.safeCall(p, () => p.flush())); + } + + /** + * 현재 로그인 상태와 문서 정보를 기반으로 공통 이벤트 속성을 만든다. + * 파라미터는 받지 않으며, 사용자 유형, 언어, 현재 경로를 담은 객체를 반환한다. + */ + private getGlobalProperties(): GlobalEventProperties { + return { + user_type: isLoggedIn() ? 'member' : 'guest', + language: document.documentElement.lang || DEFAULT_LANG, + page_path: window.location.pathname, + }; + } + + /** + * provider 호출 중 발생한 예외를 삼켜 다른 provider 전파에 영향이 없도록 한다. + * `provider`는 에러 로그 식별용 대상, `fn`은 실행할 작업이며 반환값은 없다. + */ + private safeCall(provider: AnalyticsProvider, fn: () => void): void { + try { + fn(); + } catch (error) { + console.error(`[Analytics] ${provider.name} error:`, error); + } + } +} diff --git a/src/util/analytics/constants.ts b/src/util/analytics/constants.ts new file mode 100644 index 00000000..43c8d253 --- /dev/null +++ b/src/util/analytics/constants.ts @@ -0,0 +1,21 @@ +import type { AnalyticsEventName } from './types'; + +/** 문자열 오타 없이 이벤트 이름을 재사용하기 위한 애널리틱스 이벤트 상수 맵이다. */ +export const ANALYTICS_EVENTS: Record = + { + page_view: 'page_view', + page_leave: 'page_leave', + login_started: 'login_started', + login_completed: 'login_completed', + table_shared: 'table_shared', + share_link_entered: 'share_link_entered', + timer_started: 'timer_started', + debate_completed: 'debate_completed', + debate_abandoned: 'debate_abandoned', + template_selected: 'template_selected', + template_used: 'template_used', + poll_created: 'poll_created', + poll_voted: 'poll_voted', + poll_result_viewed: 'poll_result_viewed', + feedback_timer_started: 'feedback_timer_started', + } as const; diff --git a/src/util/analytics/index.ts b/src/util/analytics/index.ts new file mode 100644 index 00000000..a2859477 --- /dev/null +++ b/src/util/analytics/index.ts @@ -0,0 +1,42 @@ +import { AnalyticsManager } from './analyticsManager'; +import { AmplitudeProvider } from './providers/amplitudeProvider'; +import { GA4Provider } from './providers/ga4Provider'; +import { NoopProvider } from './providers/noopProvider'; + +export type { + AnalyticsEventMap, + AnalyticsEventName, + AnalyticsManagerInterface, + AnalyticsProvider, + AnalyticsUserProperties, + GlobalEventProperties, +} from './types'; +export { ANALYTICS_EVENTS } from './constants'; + +/** 애널리틱스 제공자들을 통합 관리하는 전역 매니저 인스턴스다. */ +const analyticsManager = new AnalyticsManager(); + +/** + * 실행 환경에 맞는 애널리틱스 provider를 등록하고 초기화한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ +export function setupAnalytics(): void { + const isProduction = import.meta.env.MODE === 'production'; + if (isProduction) { + const amplitudeKey = import.meta.env.VITE_AMPLITUDE_API_KEY; + if (amplitudeKey) { + analyticsManager.addProvider(new AmplitudeProvider(amplitudeKey)); + } + + const gaId = import.meta.env.VITE_GOOGLE_ANALYTICS_ID; + if (gaId) { + analyticsManager.addProvider(new GA4Provider(gaId)); + } + } else { + analyticsManager.addProvider(new NoopProvider()); + } + + analyticsManager.init(); +} + +export { analyticsManager }; diff --git a/src/util/analytics/loginTrigger.ts b/src/util/analytics/loginTrigger.ts new file mode 100644 index 00000000..a4be0eb2 --- /dev/null +++ b/src/util/analytics/loginTrigger.ts @@ -0,0 +1,79 @@ +interface LoginTriggerData { + trigger_page: string; + trigger_context: + | 'landing_header' + | 'landing_table_section' + | 'share_save' + | 'timer_modal' + | 'protected_route'; +} + +interface StoredLoginTrigger extends LoginTriggerData { + timestamp: number; +} + +/** 로그인 진입 맥락을 세션 스토리지에 저장할 때 사용하는 키다. */ +const STORAGE_KEY = 'analytics_login_trigger'; +/** 저장된 로그인 진입 맥락을 유효하다고 보는 최대 유지 시간이다. */ +const EXPIRY_MS = 5 * 60 * 1000; // 5분 + +/** + * 로그인 시작 위치와 맥락을 세션 스토리지에 저장한다. + * `data`는 진입 페이지와 컨텍스트, `options.force`는 기존 값이 있어도 덮어쓸지 여부이며 반환값은 없다. + */ +export function setLoginTrigger( + data: LoginTriggerData, + options?: { force?: boolean }, +): void { + if (!options?.force && hasLoginTrigger()) { + return; + } + const stored: StoredLoginTrigger = { ...data, timestamp: Date.now() }; + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); +} + +/** + * 저장된 로그인 진입 정보를 읽고 삭제한다. + * 파라미터는 받지 않으며, 유효한 저장값이면 로그인 진입 정보 객체를, 없거나 만료/파싱 실패 시 `null`을 반환한다. + */ +export function consumeLoginTrigger(): LoginTriggerData | null { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return null; + + sessionStorage.removeItem(STORAGE_KEY); + + try { + const stored: StoredLoginTrigger = JSON.parse(raw); + if (Date.now() - stored.timestamp > EXPIRY_MS) { + return null; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { timestamp, ...data } = stored; + return data; + } catch { + return null; + } +} + +/** + * 저장된 로그인 진입 정보를 삭제한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ +export function clearLoginTrigger(): void { + sessionStorage.removeItem(STORAGE_KEY); +} + +/** + * 현재 유효한 로그인 진입 정보가 저장되어 있는지 확인한다. + * 파라미터는 받지 않으며, 만료/파싱 실패 건은 false로 본다. + */ +export function hasLoginTrigger(): boolean { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return false; + try { + const stored: StoredLoginTrigger = JSON.parse(raw); + return Date.now() - stored.timestamp <= EXPIRY_MS; + } catch { + return false; + } +} diff --git a/src/util/analytics/providers/amplitudeProvider.test.ts b/src/util/analytics/providers/amplitudeProvider.test.ts new file mode 100644 index 00000000..f0311e7f --- /dev/null +++ b/src/util/analytics/providers/amplitudeProvider.test.ts @@ -0,0 +1,67 @@ +import * as amplitude from '@amplitude/analytics-browser'; +import { AmplitudeProvider } from './amplitudeProvider'; +import type { EnrichedEventProperties, PageViewProperties } from '../types'; + +vi.mock('@amplitude/analytics-browser'); + +describe('AmplitudeProvider', () => { + let provider: AmplitudeProvider; + /** AmplitudeProvider 초기화 테스트에 사용하는 고정 API 키다. */ + const TEST_API_KEY = 'test-api-key'; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new AmplitudeProvider(TEST_API_KEY); + }); + + test('init 호출 시 amplitude.init이 API 키로 호출된다', () => { + provider.init(); + expect(amplitude.init).toHaveBeenCalledWith(TEST_API_KEY); + }); + + test('trackPageView 호출 시 amplitude.track이 page_view로 호출된다', () => { + const props: EnrichedEventProperties = { + page_title: 'Home', + previous_page_path: '', + referrer: '', + user_type: 'guest', + language: 'ko', + page_path: '/home', + }; + provider.trackPageView(props); + expect(amplitude.track).toHaveBeenCalledWith('page_view', props); + }); + + test('trackEvent 호출 시 amplitude.track이 호출된다', () => { + const props = { + organization_name: 'org', + template_name: 'tmpl', + template_label: 'org - tmpl', + user_type: 'guest' as const, + language: 'ko', + page_path: '/home', + }; + provider.trackEvent('template_selected', props); + expect(amplitude.track).toHaveBeenCalledWith('template_selected', props); + }); + + test('setUserId 호출 시 amplitude.setUserId가 user_ prefix와 함께 호출된다', () => { + provider.setUserId('42'); + expect(amplitude.setUserId).toHaveBeenCalledWith('user_42'); + }); + + test('setUserProperties 호출 시 amplitude.identify가 호출된다', () => { + provider.setUserProperties({ user_type: 'member', language: 'ko' }); + expect(amplitude.identify).toHaveBeenCalled(); + }); + + test('reset 호출 시 amplitude.reset이 호출된다', () => { + provider.reset(); + expect(amplitude.reset).toHaveBeenCalled(); + }); + + test('flush 호출 시 amplitude.flush이 호출된다', () => { + provider.flush(); + expect(amplitude.flush).toHaveBeenCalled(); + }); +}); diff --git a/src/util/analytics/providers/amplitudeProvider.ts b/src/util/analytics/providers/amplitudeProvider.ts new file mode 100644 index 00000000..4993dce5 --- /dev/null +++ b/src/util/analytics/providers/amplitudeProvider.ts @@ -0,0 +1,83 @@ +import * as amplitude from '@amplitude/analytics-browser'; +import type { + AnalyticsEventMap, + AnalyticsEventName, + AnalyticsProvider, + AnalyticsUserProperties, + EnrichedEventProperties, + PageViewProperties, +} from '../types'; + +/** Amplitude SDK 호출을 프로젝트 공통 인터페이스로 감싸는 provider다. */ +export class AmplitudeProvider implements AnalyticsProvider { + readonly name = 'Amplitude'; + + /** + * Amplitude 초기화에 필요한 API 키를 보관한다. + * `apiKey`는 Amplitude 프로젝트 API 키이며 반환값은 없다. + */ + constructor(private apiKey: string) {} + + /** + * Amplitude SDK를 API 키로 초기화한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + init(): void { + amplitude.init(this.apiKey); + } + + /** + * 페이지뷰 이벤트를 Amplitude에 전송한다. + * `properties`는 공통 필드가 포함된 페이지뷰 속성이며 반환값은 없다. + */ + trackPageView(properties: EnrichedEventProperties): void { + amplitude.track('page_view', properties); + } + + /** + * 임의의 애널리틱스 이벤트를 Amplitude에 전송한다. + * `eventName`은 이벤트 이름, `properties`는 공통 필드가 포함된 이벤트 속성이며 반환값은 없다. + */ + trackEvent( + eventName: T, + properties: EnrichedEventProperties, + ): void { + amplitude.track(eventName, properties); + } + + /** + * Amplitude 사용자 식별자를 설정한다. + * `userId`는 서비스 사용자 ID이며, 반환값은 없다. + */ + setUserId(userId: string): void { + amplitude.setUserId(`user_${userId}`); + } + + /** + * 사용자 속성을 Identify 객체로 만들어 Amplitude에 반영한다. + * `properties`는 사용자 유형과 언어 등의 속성이며 반환값은 없다. + */ + setUserProperties(properties: AnalyticsUserProperties): void { + const identify = new amplitude.Identify(); + Object.entries(properties).forEach(([key, value]) => { + identify.set(key, value); + }); + amplitude.identify(identify); + } + + /** + * Amplitude에 저장된 사용자 상태를 초기화한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + reset(): void { + amplitude.reset(); + } + + /** + * 대기 중인 Amplitude 이벤트 전송을 요청한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + flush(): void { + amplitude.flush(); + } +} diff --git a/src/util/analytics/providers/ga4Provider.test.ts b/src/util/analytics/providers/ga4Provider.test.ts new file mode 100644 index 00000000..14236fc4 --- /dev/null +++ b/src/util/analytics/providers/ga4Provider.test.ts @@ -0,0 +1,45 @@ +import ReactGA from 'react-ga4'; +import { GA4Provider } from './ga4Provider'; +import type { EnrichedEventProperties, PageViewProperties } from '../types'; + +vi.mock('react-ga4'); + +describe('GA4Provider', () => { + let provider: GA4Provider; + /** GA4Provider 초기화 테스트에 사용하는 고정 측정 ID다. */ + const TEST_GA_ID = 'G-TEST'; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new GA4Provider(TEST_GA_ID); + }); + + test('trackPageView 호출 시 ReactGA.send가 호출된다', () => { + const props: EnrichedEventProperties = { + page_title: 'Home', + previous_page_path: '', + referrer: '', + user_type: 'guest', + language: 'ko', + page_path: '/home', + }; + provider.trackPageView(props); + expect(ReactGA.send).toHaveBeenCalledWith({ + hitType: 'pageview', + page: '/home', + title: 'Home', + }); + }); + + test('trackEvent 호출 시 ReactGA.event가 호출된다', () => { + const props = { + table_id: 1 as number | 'guest', + total_rounds: 5, + user_type: 'guest' as const, + language: 'ko', + page_path: '/timer', + }; + provider.trackEvent('timer_started', props); + expect(ReactGA.event).toHaveBeenCalledWith('timer_started', props); + }); +}); diff --git a/src/util/analytics/providers/ga4Provider.ts b/src/util/analytics/providers/ga4Provider.ts new file mode 100644 index 00000000..70731445 --- /dev/null +++ b/src/util/analytics/providers/ga4Provider.ts @@ -0,0 +1,84 @@ +import ReactGA from 'react-ga4'; +import type { + AnalyticsEventMap, + AnalyticsEventName, + AnalyticsProvider, + AnalyticsUserProperties, + EnrichedEventProperties, + PageViewProperties, +} from '../types'; + +/** React GA4 SDK 호출을 프로젝트 공통 인터페이스로 감싸는 provider다. */ +export class GA4Provider implements AnalyticsProvider { + readonly name = 'GA4'; + + /** + * GA4 초기화에 필요한 측정 ID를 보관한다. + * `gaId`는 Google Analytics 측정 ID이며 반환값은 없다. + */ + constructor(private gaId: string) {} + + /** + * GA4 SDK를 측정 ID로 초기화한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + init(): void { + ReactGA.initialize(this.gaId); + } + + /** + * 페이지뷰 속성을 GA4 pageview 형식으로 변환해 전송한다. + * `properties`는 공통 필드가 포함된 페이지뷰 속성이며 반환값은 없다. + */ + trackPageView(properties: EnrichedEventProperties): void { + ReactGA.send({ + hitType: 'pageview', + page: properties.page_path, + title: properties.page_title, + }); + } + + /** + * 임의의 애널리틱스 이벤트를 GA4에 전송한다. + * `eventName`은 이벤트 이름, `properties`는 공통 필드가 포함된 이벤트 속성이며 반환값은 없다. + */ + trackEvent( + eventName: T, + properties: EnrichedEventProperties, + ): void { + ReactGA.event(eventName, properties); + } + + /** + * GA4에 사용자 식별자를 설정한다. + * `userId`는 서비스 사용자 ID이며, 반환값은 없다. + */ + setUserId(userId: string): void { + ReactGA.set({ user_id: userId }); + } + + /** + * GA4에서는 사용자 속성을 직접 처리하지 않으므로 빈 구현으로 둔다. + * `_properties`는 맞춰진 인터페이스용 파라미터이며 반환값은 없다. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setUserProperties(_properties: AnalyticsUserProperties): void { + // GA4 handles user properties via gtag config, not directly + } + + /** + * GA4에 설정된 사용자 식별자를 초기화한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + reset(): void { + ReactGA.set({ user_id: null }); + } + + /** + * GA4는 즉시 전송 방식이므로 별도 flush 동작 없이 종료한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + flush(): void { + // GA4 sends events immediately, no flush needed + } +} diff --git a/src/util/analytics/providers/noopProvider.ts b/src/util/analytics/providers/noopProvider.ts new file mode 100644 index 00000000..61e35aca --- /dev/null +++ b/src/util/analytics/providers/noopProvider.ts @@ -0,0 +1,42 @@ +import type { AnalyticsProvider } from '../types'; + +/** 실제 SDK 없이 동일한 인터페이스를 만족시키는 no-op provider다. */ +export class NoopProvider implements AnalyticsProvider { + readonly name = 'Noop'; + + /** + * 개발 환경에서 초기화 호출만 흡수한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + init(): void {} + /** + * 개발 환경에서 페이지뷰 전송 호출만 흡수한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + trackPageView(): void {} + /** + * 개발 환경에서 이벤트 전송 호출만 흡수한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + trackEvent(): void {} + /** + * 개발 환경에서 사용자 ID 설정 호출만 흡수한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + setUserId(): void {} + /** + * 개발 환경에서 사용자 속성 설정 호출만 흡수한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + setUserProperties(): void {} + /** + * 개발 환경에서 상태 초기화 호출만 흡수한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + reset(): void {} + /** + * 개발 환경에서 flush 호출만 흡수한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ + flush(): void {} +} diff --git a/src/util/analytics/templateOrigin.ts b/src/util/analytics/templateOrigin.ts new file mode 100644 index 00000000..4329bad8 --- /dev/null +++ b/src/util/analytics/templateOrigin.ts @@ -0,0 +1,39 @@ +interface TemplateOriginData { + organization_name: string; + template_name: string; + template_label: string; +} + +/** 템플릿 출처 정보를 세션 스토리지에 저장할 때 사용하는 키다. */ +const STORAGE_KEY = 'analytics_template_origin'; + +/** + * 선택한 템플릿의 출처 정보를 세션 스토리지에 저장한다. + * `data`는 조직명, 템플릿명, 표시 라벨을 담은 객체이며 반환값은 없다. + */ +export function setTemplateOrigin(data: TemplateOriginData): void { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data)); +} + +/** + * 저장된 템플릿 출처 정보를 한 번만 읽고 삭제한다. + * 파라미터는 받지 않으며, 저장값이 유효하면 출처 객체를, 없거나 파싱에 실패하면 `null`을 반환한다. + */ +export function consumeTemplateOrigin(): TemplateOriginData | null { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return null; + sessionStorage.removeItem(STORAGE_KEY); + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +/** + * 세션 스토리지에 남아 있는 템플릿 출처 정보를 삭제한다. + * 파라미터는 받지 않으며, 반환값은 없다. + */ +export function clearTemplateOrigin(): void { + sessionStorage.removeItem(STORAGE_KEY); +} diff --git a/src/util/analytics/types.ts b/src/util/analytics/types.ts new file mode 100644 index 00000000..4942e417 --- /dev/null +++ b/src/util/analytics/types.ts @@ -0,0 +1,162 @@ +/** 모든 이벤트에 포함되는 공통 속성 */ +export interface GlobalEventProperties { + user_type: 'member' | 'guest'; + language: string; + page_path: string; +} + +/** 페이지뷰 이벤트 속성 */ +export interface PageViewProperties { + page_title: string; + previous_page_path: string; + referrer: string; +} + +/** 페이지 이탈 이벤트 속성 */ +export interface PageLeaveProperties { + page_title: string; + page_path: string; + duration_ms: number; +} + +/** 로그인 시작 이벤트 속성 */ +export interface LoginStartedProperties { + trigger_page: string; + trigger_context: + | 'landing_header' + | 'landing_table_section' + | 'share_save' + | 'timer_modal' + | 'protected_route' + | 'unknown'; +} + +/** 로그인 완료 이벤트 속성 */ +export interface LoginCompletedProperties extends LoginStartedProperties { + member_id: number; +} + +/** 시간표 공유 이벤트 속성 */ +export interface TableSharedProperties { + table_id: number | 'guest'; +} + +/** 공유 링크 유입 이벤트 속성 */ +export interface ShareLinkEnteredProperties { + referrer: string; +} + +/** 타이머 시작 이벤트 속성 */ +export interface TimerStartedProperties { + table_id: number | 'guest'; + total_rounds: number; +} + +/** 토론 완료 이벤트 속성 */ +export interface DebateCompletedProperties { + table_id: number | 'guest'; + total_rounds: number; +} + +/** 토론 이탈 이벤트 속성 */ +export interface DebateAbandonedProperties { + table_id: number | 'guest'; + current_round: number; + total_rounds: number; + abandon_type: 'navigation' | 'unload' | 'visibility'; +} + +/** 템플릿 선택 이벤트 속성 */ +export interface TemplateSelectedProperties { + organization_name: string; + template_name: string; + template_label: string; // "{organization_name} - {template_name}" 조합값 +} + +/** 템플릿 사용 이벤트 속성 */ +export interface TemplateUsedProperties extends TemplateSelectedProperties { + table_id: number | 'guest'; +} + +/** 투표 생성 이벤트 속성 */ +export interface PollCreatedProperties { + table_id: number; + poll_id: number; +} + +/** 투표 참여 이벤트 속성 */ +export interface PollVotedProperties { + poll_id: number; + team: string; +} + +/** 투표 결과 조회 이벤트 속성 */ +export interface PollResultViewedProperties { + poll_id: number; +} + +/** 피드백 타이머 시작 이벤트 속성 */ +export interface FeedbackTimerStartedProperties { + table_id: number; +} + +/** 이벤트 이름 → 속성 타입 매핑 */ +export interface AnalyticsEventMap { + page_view: PageViewProperties; + page_leave: PageLeaveProperties; + login_started: LoginStartedProperties; + login_completed: LoginCompletedProperties; + table_shared: TableSharedProperties; + share_link_entered: ShareLinkEnteredProperties; + timer_started: TimerStartedProperties; + debate_completed: DebateCompletedProperties; + debate_abandoned: DebateAbandonedProperties; + template_selected: TemplateSelectedProperties; + template_used: TemplateUsedProperties; + poll_created: PollCreatedProperties; + poll_voted: PollVotedProperties; + poll_result_viewed: PollResultViewedProperties; + feedback_timer_started: FeedbackTimerStartedProperties; +} + +/** 이벤트 이름 유니온 타입 */ +export type AnalyticsEventName = keyof AnalyticsEventMap; + +/** 사용자 속성 */ +export interface AnalyticsUserProperties { + user_type: 'member' | 'guest'; + language: string; +} + +/** 글로벌 필드가 합성된 최종 페이로드 */ +export type EnrichedEventProperties = T & GlobalEventProperties; + +/** 각 분석 도구가 구현해야 하는 인터페이스 */ +export interface AnalyticsProvider { + readonly name: string; + init(): void; + trackPageView(properties: EnrichedEventProperties): void; + trackEvent( + eventName: T, + properties: EnrichedEventProperties, + ): void; + setUserId(userId: string): void; + setUserProperties(properties: AnalyticsUserProperties): void; + reset(): void; + flush(): void; +} + +/** 여러 Provider에 이벤트를 팬아웃하는 매니저 인터페이스 */ +export interface AnalyticsManagerInterface { + addProvider(provider: AnalyticsProvider): void; + init(): void; + trackPageView(properties: PageViewProperties): void; + trackEvent( + eventName: T, + properties: AnalyticsEventMap[T], + ): void; + setUserId(userId: string): void; + setUserProperties(properties: AnalyticsUserProperties): void; + reset(): void; + flush(): void; +} diff --git a/src/util/setupGoogleAnalytics.tsx b/src/util/setupGoogleAnalytics.tsx deleted file mode 100644 index 70439b70..00000000 --- a/src/util/setupGoogleAnalytics.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import ReactGA from 'react-ga4'; - -export function setupGoogleAnalytics(): void { - const GA_ID = import.meta.env.VITE_GOOGLE_ANALYTICS_ID; - const isProduction = import.meta.env.MODE === 'production'; - - if (!GA_ID || !isProduction) return; - - ReactGA.initialize(GA_ID); - ReactGA.send('pageview'); -} diff --git a/tsconfig.app.json b/tsconfig.app.json index bf9ca5cb..6896347f 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "types": ["vitest", "node", "@testing-library/jest-dom"], + "types": ["vitest", "vitest/globals", "node", "@testing-library/jest-dom"], "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true,