diff --git a/docs/GA4-Data-Taxonomy.md b/docs/GA4-Data-Taxonomy.md new file mode 100644 index 0000000..d7deab9 --- /dev/null +++ b/docs/GA4-Data-Taxonomy.md @@ -0,0 +1,309 @@ +# GA4 Data Taxonomy + +이 문서는 LinKU의 Chrome Extension 환경에서 `GA4 Measurement Protocol`만 사용해 +기본적인 제품 분석과 retention 분석을 가능하게 하기 위한 이벤트 taxonomy다. + +모든 이벤트는 `src/utils/analytics.ts`의 도메인 헬퍼를 통해 전송된다. +이 문서는 "무엇을 왜 측정할 것인가"와 **현재 구현 상태**를 함께 정의한다. + +## Goals + +| 목표 | 분석 질문 | 핵심 지표 | +| --- | --- | --- | +| Retention 파악 | 사용자가 설치/첫 사용 후 다시 돌아오는가 | first-open cohort retention, weekly returning users | +| 핵심 가치 행동 파악 | 어떤 행동이 LinKU의 핵심 가치를 보여주는가 | link click rate, template apply rate, alert/todo usage | +| 기능 채택 파악 | 사용자가 어떤 기능을 실제로 쓰는가 | feature adoption by domain | +| 템플릿 기능 성과 파악 | 템플릿 생성/저장/동기화/게시 흐름이 잘 작동하는가 | editor conversion funnel | +| 계정 연동 파악 | 로그인/이메일 인증이 사용성에 어떤 영향을 주는가 | login start -> success, guest -> verified | + +## Principles + +| 원칙 | 설명 | +| --- | --- | +| MP-only | `gtag.js` 없이 Measurement Protocol만 유지한다 | +| 제품 중심 naming | GA4 자동 이벤트 흉내보다 LinKU 제품 행위를 이름으로 정의한다 | +| Low-cardinality first | 리포트와 Explore에서 바로 쓰기 쉽도록 고정된 enum 파라미터를 우선 사용한다 | +| One action, one event | 버튼 클릭 자체보다 제품 의미가 있는 행동을 우선 이벤트로 올린다 | +| Outcome + context | 이벤트 이름은 행동, 파라미터는 맥락으로 설계한다 | +| Single module | 모든 이벤트 정의는 `analytics.ts` 안에서만 이루어진다. 호출 지점은 도메인 헬퍼만 import한다 | + +## Identity Model + +| 항목 | 정책 | +| --- | --- | +| User key | `client_id`를 기기/브라우저 단위 식별자로 사용 | +| Session key | 30분 inactivity 기준 `session_id` 사용 | +| Logged-in identity | 당장은 별도 `user_id` 없이 유지, 필요 시 추후 회원 ID 해시 검토 | +| Environment | `VITE_ENVIRONMENT=development` 일 때 `debug_mode: 1` 파라미터 포함, production endpoint로 전송해 GA4 DebugView에서 확인 | + +## Recommended Event Naming + +| Prefix | 용도 | 예시 | +| --- | --- | --- | +| `extension_` | 확장 프로그램 lifecycle | `extension_first_open` | +| `navigation_` | 화면/탭/진입 | `navigation_tab_select` | +| `link_` | 핵심 링크 사용 | `link_open` | +| `template_` | 템플릿 생성/편집/배포 | `template_publish_success` | +| `auth_` | 로그인/인증 | `auth_login_success` | +| `alerts_` | 공지 기능 | `alerts_item_open` | +| `todo_` | Todo 기능 | `todo_item_create` | +| `labs_` | Labs 기능 | `labs_feature_use` | +| `settings_` | 설정 변경 | `settings_credentials_saved` | +| `system_` | 오류/상태 | `system_error` | + +## Global Parameters + +아래 파라미터는 가능한 한 여러 이벤트에서 공통적으로 재사용한다. + +| Param | 타입 | 설명 | 예시 | +| --- | --- | --- | --- | +| `screen_name` | string | 현재 화면/페이지 | `popup_home`, `template_editor` | +| `entry_point` | string | 진입 경로 | `popup`, `settings_dialog`, `gallery_page` | +| `feature_area` | string | 상위 기능 영역 | `links`, `templates`, `alerts`, `todo`, `labs`, `auth` | +| `ui_location` | string | UI 내 위치 | `header`, `tab_bar`, `card_action`, `dialog` | +| `template_id` | number | 템플릿 식별자 | `12345` | +| `template_origin` | string | 템플릿 출처 | `default`, `owned`, `cloned`, `posted`, `local_only` | +| `result` | string | 성공/실패/취소 | `success`, `fail`, `cancel` | +| `error_code` | string | 실패 코드 | `network_error`, `auth_required` | +| `error_message` | string | 사람이 읽는 에러 설명 | `sync_failed` | +| `is_logged_in` | boolean | 로그인 상태 | `true` | +| `is_guest` | boolean | 게스트 회원 여부 | `false` | + +> **구현 상태 표기** +> - `구현됨` — `analytics.ts`에 헬퍼 함수가 존재하고 call site에 연결됨 +> - `미구현` — taxonomy에 정의됐으나 아직 헬퍼/call site 없음 + +## Lifecycle Events + +Retention과 기본 활성 사용자 분석을 위해 가장 먼저 도입해야 할 이벤트군이다. +`sendExtensionOpen(screenName, entryPoint)` 한 번 호출로 아래 세 이벤트를 자동 처리한다. + +| Event Name | 상태 | 목적 | Trigger | 주요 Params | 우선순위 | +| --- | --- | --- | --- | --- | --- | +| `extension_first_open` | 구현됨 | 첫 사용 cohort 정의 | `firstOpenSent` 플래그 없을 때 1회 | `screen_name`, `entry_point` | P0 | +| `extension_session_start` | 구현됨 | 재방문/세션 기준 정의 | 30분 초과로 새 `session_id` 생성 시 | `screen_name`, `entry_point` | P0 | +| `extension_open` | 구현됨 | 실제 사용 시작 기록 | popup mount 시 매번 | `screen_name`, `entry_point` | P0 | + +## Core Product Events + +LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측정하기 위한 이벤트다. + +| Event Name | 상태 | 목적 | 주요 Params | 비고 | +| --- | --- | --- | --- | --- | +| `navigation_tab_select` | 구현됨 | 어떤 탭이 실제로 사용되는지 | `tab_name`, `feature_area?` | `ui_location`은 미전송 | +| `search_submit` | 구현됨 | 검색 기능 사용률 측정 | `search_term`, `search_location?` | | +| `link_open` | 구현됨 | LinKU 핵심 가치 행동 | `link_name`, `link_url`, `link_group?`, `same_host_variant?` | same-host 버튼은 `samehost_primary` / `samehost_secondary`로 구분 | +| `banner_open` | 구현됨 | 배너 클릭 효율 측정 | `banner_id`, `banner_title`, `banner_position` | | +| `button_click` | 구현됨 | 범용 버튼 클릭 (header 등) | `button_name`, `button_location?` | 제품 의미가 큰 버튼은 개별 이벤트로 승격, header 잡버튼에만 사용 | + +## Account / Auth Events + +| Event Name | 상태 | 목적 | 주요 Params | 우선순위 | +| --- | --- | --- | --- | --- | +| `auth_login_start` | 구현됨 | 로그인 의도 파악 | `provider`, `ui_location` | P1 | +| `auth_login_success` | 구현됨 | 실제 로그인 성공률 측정 | `provider`, `is_guest` | P1 | +| `auth_login_fail` | 구현됨 | 로그인 장애 파악 | `provider`, `error_code`, `error_message` | P1 | +| `auth_logout` | 구현됨 | 로그아웃 행동 파악 | `ui_location` | P2 | +| `auth_email_verification_start` | 구현됨 | 게스트 → 회원 전환 시작점 | `ui_location` | P1 | +| `auth_email_verification_success` | 구현됨 | 회원 전환 완료 | `domain_type` | P1 | + +## Template Events + +템플릿 기능은 LinKU의 차별화 기능이라 별도 도메인으로 관리한다. + +| Event Name | 상태 | 목적 | 주요 Params | 우선순위 | +| --- | --- | --- | --- | --- | +| `template_editor_open` | 구현됨 | 에디터 진입률 측정 | `template_origin`, `template_id?` | P1 | +| `template_create_start` | 구현됨 | 새 템플릿 생성 진입 | `template_origin`=`default\|empty` | P1 | +| `template_name_edit` | 구현됨 | 에디터 사용성 파악 | `template_id` | P3 | +| `template_item_add` | 구현됨 | 에디터 내 핵심 편집 행위 | `add_method`(`drag`\|`button`), `template_id?` | P1 | +| `template_item_update` | 구현됨 | 링크/아이콘/속성 수정 | `update_type`, `template_id` | P2 | +| `template_item_delete` | 구현됨 | 아이템 삭제 행위 | `delete_source`, `template_id` | P2 | +| `template_save_success` | 구현됨 | 로컬 저장 완료 | `template_id`, `template_origin`, `item_count` | P0 | +| `template_save_fail` | 구현됨 | 저장 실패 원인 파악 | `template_id`, `error_code`, `error_message` | P1 | +| `template_sync_success` | 구현됨 | 서버 동기화 성공 | `template_id`, `item_count` | P0 | +| `template_sync_fail` | 구현됨 | 동기화 실패 | `template_id`, `error_code`, `error_message` | P1 | +| `template_publish_success` | 구현됨 | 갤러리 게시 성공 | `template_id`, `item_count` | P0 | +| `template_publish_fail` | 구현됨 | 게시 실패 | `template_id`, `error_code`, `error_message` | P1 | +| `template_apply` | 구현됨 | 실제 메인 화면 적용 행동 | `template_id`, `template_origin`, `is_default` | P0 | +| `template_delete` | 구현됨 | 템플릿 삭제 | `template_id`, `template_origin`, `sync_status` | P2 | +| `template_gallery_open` | 구현됨 | 갤러리 진입 | `entry_point` | P1 | +| `template_gallery_search` | 구현됨 | 갤러리 검색 및 정렬 사용 | `query_length`, `sort_option` | P2 | +| `template_gallery_sort_change` | 통합됨 | 정렬 변경 | `template_gallery_search`의 `sort_option`으로 통합 | P3 | +| `template_clone_success` | 구현됨 | 공개 템플릿 복제 성공 | `posted_template_id`, `is_author_id_present` | P1 | +| `template_clone_fail` | 구현됨 | 복제 실패 | `posted_template_id`, `error_code`, `error_message?` | P2 | +| `template_like_toggle` | 구현됨 | 좋아요 사용 | `posted_template_id`, `is_liked` | P2 | + +## Alerts / Todo / Labs Events + +기본 수준의 크롬 확장 프로그램 분석을 위해 보조 기능도 최소한의 adoption 이벤트는 남겨야 한다. + +| Event Name | 상태 | 목적 | 주요 Params | 우선순위 | +| --- | --- | --- | --- | --- | +| `alerts_view_open` | 구현됨 | 공지 탭 사용 여부 | `view_mode`(`all`\|`my`), `category` | P2 | +| `alerts_item_open` | 구현됨 | 공지 클릭률 | `alert_id`, `category`, `source`(`general`\|`department`) | P2 | +| `alerts_subscription_change` | 구현됨 | 개인화 기능 사용 | `category`, `result` | P3 | +| `todo_view_open` | 구현됨 | Todo 기능 사용 여부 | `todo_count` | P2 | +| `todo_item_create` | 구현됨 | Todo 입력 | `source`, `has_due_date` | P2 | +| `todo_item_complete` | 구현됨 | Todo 완료율 | `item_type`(`custom`\|`ecampus`) | P2 | +| `todo_item_delete` | 구현됨 | Todo 삭제 | `item_type`(`custom`\|`ecampus`) | P3 | +| `labs_view_open` | 구현됨 | Labs 진입 | `feature_name` | P3 | +| `labs_feature_use` | 구현됨 | Labs 세부 기능 사용 | `feature_name`, `result` | P3 | + +## Settings / System Events + +| Event Name | 상태 | 목적 | 주요 Params | 우선순위 | +| --- | --- | --- | --- | --- | +| `settings_open` | 구현됨 | 설정 진입 측정 | `entry_point` | P2 | +| `settings_credentials_saved` | 구현됨 | eCampus 계정 저장 | `result`=`success` | P1 | +| `settings_credentials_deleted` | 구현됨 | eCampus 계정 삭제 | `result`=`success` | P2 | +| `system_error` | 구현됨 | runtime 오류 집계 | `error_code`, `error_message`, `screen_name?` | P1 | + +## Migration History + +### v1.5.46 → GA4-MP 브랜치 (최초 택소노미 도입) + +| 구 이벤트 | 대체된 이벤트 | +| --- | --- | +| `page_view` | 유지 (sendExtensionOpen과 함께 sendPageView 병렬 호출) | +| `search` | `MP_search_submit` | +| `tab_change` | 유지 (sendTabChange — 연속성 보존) | +| `link_click` | 유지 (sendLinkClick — 연속성 보존, 파라미터 link_group/same_host_variant 추가) | +| `setting_change` | 유지 + `MP_settingsCredentials_save` / `MP_settingsCredentials_delete` 병렬 발송 | +| `error` | 유지 (sendError — 연속성 보존, 파라미터 error_code/screen_name 추가) | +| `button_click("google_login")` | `MP_authLogin_start` + `MP_authLogin_success` / `MP_authLogin_fail` | +| `button_click("google_logout")` | `MP_auth_logout` | + +### 택소노미 컨벤션 정립 (이번 작업) + +| 구 이벤트명 | 신규 이벤트명 | 사유 | +| --- | --- | --- | +| `search_submit` | `MP_search_submit` | MP_ prefix 적용 | +| `auth_login_start/success/fail` | `MP_authLogin_start/success/fail` | prefix + camelCase trio | +| `auth_email_verification_start/success` | `MP_authEmailVerification_start/success` | prefix + camelCase | +| `auth_logout` | `MP_auth_logout` | prefix 적용 | +| `settings_open` | `MP_settings_open` | prefix 적용 | +| `settings_credentials_saved/deleted` | `MP_settingsCredentials_save/delete` | prefix + camelCase, 이벤트명은 동작형 save/delete 사용 | +| `template_editor_open` | `MP_templateEditor_view` | prefix + _view 진입 컨벤션 | +| `template_create_start` | `MP_template_createStart` | prefix + camelCase action | +| `template_item_add/update/delete` | `MP_templateItem_add/update/delete` | prefix + camelCase object | +| `template_save/sync/publish_success/fail` | `MP_templateSave/Sync/Publish_success/fail` | prefix + camelCase trio | +| `template_apply` | `MP_template_apply` | prefix 적용 | +| `template_delete` | `MP_template_delete` | prefix 적용 | +| `template_gallery_open` | `MP_templateGallery_view` | prefix + _view 진입 컨벤션 | +| `template_gallery_search` | `MP_templateGallery_search` | prefix + camelCase object | +| `template_gallery_sort_change` | **제거** | P3 — gallery_search의 sort_option에 흡수 | +| `template_clone_success/fail` | `MP_templateClone_success/fail` | prefix + camelCase | +| `template_like_toggle` | `MP_template_likeToggle` | prefix + camelCase action | +| `template_name_edit` | **제거** | P3 — 분석 활용도 낮음, save_success에 함축 | +| `alerts_view_open` | `MP_alerts_view` | prefix + _view 진입 컨벤션 | +| `alerts_item_open` | `MP_alertsItem_open` | prefix + camelCase object | +| `alerts_subscription_change` | `MP_alertsSubscription_update` | prefix + _change 레거시 전용 규칙 | +| `todo_view_open` | `MP_todo_view` | prefix + _view 진입 컨벤션 | +| `todo_item_create/complete/delete` | `MP_todoItem_create/complete/delete` | prefix + camelCase object | +| `labs_view_open` | `MP_labs_open` | prefix + _open 다이얼로그 컨벤션 | +| `labs_feature_use` | `MP_labsFeature_use` | prefix + camelCase object | +| `banner_open` | `MP_banner_open` | prefix 적용 | + +## MVP Recommendation + +가장 먼저 붙일 이벤트 묶음이다. 이 정도면 GA4 Explore에서 기본 retention과 핵심 사용 흐름을 볼 수 있다. + +| 순위 | 이벤트 | 상태 | +| --- | --- | --- | +| P0 | `extension_first_open` | 구현됨 | +| P0 | `extension_session_start` | 구현됨 | +| P0 | `extension_open` | 구현됨 | +| P0 | `link_open` | 구현됨 | +| P0 | `template_save_success` | 구현됨 | +| P0 | `template_sync_success` | 구현됨 | +| P0 | `template_publish_success` | 구현됨 | +| P0 | `template_apply` | 구현됨 | +| P1 | `auth_login_start` | 구현됨 | +| P1 | `auth_login_success` | 구현됨 | +| P1 | `auth_email_verification_success` | 구현됨 | +| P1 | `template_editor_open` | 구현됨 | +| P1 | `template_item_add` | 구현됨 | +| P1 | `system_error` | 구현됨 | + +## Suggested Explore Reports + +| 리포트 | 포함 이벤트 | +| --- | --- | +| First-open retention cohort | `extension_first_open` → `extension_open` | +| Session-based return cohort | `extension_session_start` | +| Core action retention | `extension_first_open` cohort + `link_open` return condition | +| Template funnel | `template_editor_open` → `template_item_add` → `template_save_success` → `template_sync_success` → `template_publish_success` | +| Auth funnel | `auth_login_start` → `auth_login_success` → `auth_email_verification_success` | + +## Non-Goals + +| 항목 | 이유 | +| --- | --- | +| 모든 버튼을 다 추적 | 분석 가치보다 cardinality와 유지보수 비용이 큼 | +| GA4 자동 이벤트 완전 복제 | MP-only에서는 비용 대비 효과가 낮음 | +| 처음부터 모든 기능 100% 추적 | retention과 핵심 가치 행동부터 붙이는 편이 낫다 | + +--- + +## 구현된 이벤트 전체 레퍼런스 + +`src/utils/analytics.ts`에 정의된 헬퍼 기준 알파벳 순 정리. +모든 헬퍼는 `sendGAEvent` (internal) → GA4 MP `/mp/collect`로 전송된다. + +### 레거시 이벤트 (v1.5.46~, MP_ prefix 없음, 연속성 유지) + +| 이벤트명 | 항목 | 수집 목적 | 수집 속성 | 사용 코드 | 비고 | +| --- | --- | --- | --- | --- | --- | +| `button_click` | 버튼 클릭 | 미승격 버튼 범용 클릭 | `button_name`, `button_location?` | `MainLayout.tsx` (logo·settings·github), `SettingsDialog.tsx` (password_toggle·open_template_list) | 제품 의미 큰 버튼은 개별 MP_ 이벤트로 승격 | +| `error` | 렌더 오류 발생 | runtime 오류 집계 | `error_code`, `error_message`, `screen_name?` | `App.tsx · ErrorBoundary onError` | React 렌더 오류만 포착 | +| `link_click` | 링크 클릭 | LinKU 핵심 가치 행동 측정 | `link_name`, `link_url`, `link_group?`, `same_host_variant?` | `LinkGroup.tsx · GridItem onClick`, `GridItemSameHost onClick` | - | +| `page_view` | 팝업 열기 | 실제 사용 시작 기록 (레거시) | `page_title`, `page_location`, `page_referrer` | `App.tsx · useEffect → sendPageView` | sendExtensionOpen과 함께 호출 | +| `setting_change` | 계정 정보 변경 | eCampus 자격증명 변경 파악 (레거시) | `setting_name`, `setting_value` | `SettingsDialog.tsx` 내부 병렬 발송 | MP_settingsCredentials_* 와 동시 발화 | +| `tab_change` | 탭 선택 | 탭 사용 패턴 파악 | `tab_name`, `feature_area?` | `TabsLayout.tsx · handleTabChange` | - | + +### 신규 이벤트 (택소노미 정립 후, MP_ prefix) + +| 이벤트명 | 항목 | 수집 목적 | 수집 속성 | 사용 코드 | 비고 | +| --- | --- | --- | --- | --- | --- | +| `extension_first_open` | 최초 실행 | 첫 사용 cohort 정의 | `screen_name`, `entry_point` | `App.tsx · sendExtensionOpen` 내부 자동 처리 | `firstOpenSent` 플래그로 기기당 1회 보장 | +| `extension_open` | 팝업 열기 | 실제 사용 시작 기록 | `screen_name`, `entry_point` | `App.tsx · useEffect → sendExtensionOpen` | - | +| `extension_session_start` | 세션 시작 | 세션 기준 정의 | `screen_name`, `entry_point` | `App.tsx · sendExtensionOpen` 내부 자동 처리 | 30분 inactivity 초과 시에만 전송 | +| `MP_alerts_view` | 공지 탭 진입 | 공지 탭 사용 여부 | `view_mode`, `category` | `Alerts.tsx · initialize()` | - | +| `MP_alertsItem_open` | 공지 클릭 | 공지 클릭률 측정 | `alert_id`, `category`, `source` | `AlertItem.tsx · handleClick` | - | +| `MP_alertsSubscription_update` | 구독 변경 | 학과 구독 변경 파악 | `category`, `subscription_result`(`subscribe`\|`unsubscribe`) | `SubscriptionManager.tsx · handleSubscribe`, `handleUnsubscribe` | - | +| `MP_authEmailVerification_start` | 이메일 인증 시작 | 게스트→회원 전환 시작점 | `ui_location` | `EmailVerificationDialog.tsx · useEffect([open])` | 다이얼로그 재진입마다 전송 → funnel 시작 수 과집계 가능 | +| `MP_authEmailVerification_success` | 이메일 인증 완료 | 회원 전환 완료 | `domain_type` | `EmailVerificationDialog.tsx · handleVerifyCode` | - | +| `MP_authLogin_fail` | 로그인 실패 | 로그인 장애 파악 | `provider`, `error_code`, `error_message` | `SettingsDialog.tsx · handleGoogleLogin` (결과·예외 분기) | - | +| `MP_authLogin_start` | 로그인 시도 | 로그인 의도 파악 | `provider`, `ui_location` | `SettingsDialog.tsx · handleGoogleLogin` | - | +| `MP_authLogin_success` | 로그인 성공 | 실제 로그인 성공률 측정 | `provider`, `is_guest` | `SettingsDialog.tsx · handleGoogleLogin` | - | +| `MP_auth_logout` | 로그아웃 | 로그아웃 행동 파악 | `ui_location` | `SettingsDialog.tsx · handleLogout` | - | +| `MP_banner_open` | 배너 클릭 | 배너 클릭 효율 측정 | `banner_id`, `banner_title`, `banner_position` | `ImageCarousel.tsx · Image onClick` | - | +| `MP_labsFeature_use` | Labs 기능 사용 | Labs 기능 사용 측정 | `feature_name`, `result?` | `QRGeneratorSection.tsx · regenerate()`, `LibrarySeatSection.tsx · handleOpenRoom` | ServerClockSection 미연결 | +| `MP_labs_open` | Labs 다이얼로그 진입 | Labs 탭 진입 파악 | (없음) | `LabsDialog.tsx · useEffect([open])` | - | +| `MP_search_submit` | 검색 실행 | 검색 기능 사용률 측정 | `search_term`, `search_location?` | `MainLayout.tsx · Header onKeyDown(Enter)` | - | +| `MP_settings_open` | 설정 열기 | 설정 진입 측정 | `entry_point` | `MainLayout.tsx · Settings onClick` | button_click(settings_icon)과 중복 없음 (sendSettingsOpen 직접 중복 제거됨) | +| `MP_settingsCredentials_delete` | 계정 정보 삭제 | eCampus 계정 삭제 파악 | `result`(=`success`) | `SettingsDialog.tsx · deleteCredentials` | setting_change 레거시와 병렬 발송 | +| `MP_settingsCredentials_save` | 계정 정보 저장 | eCampus 계정 저장 파악 | `result`(=`success`) | `SettingsDialog.tsx · saveCredentials` | setting_change 레거시와 병렬 발송 | +| `MP_template_apply` | 템플릿 적용 | 메인 화면 적용 — 핵심 가치 행동 | `template_id`, `template_origin`, `is_default` | `TemplateListPage.tsx · handleApplyTemplate` | - | +| `MP_template_createStart` | 템플릿 생성 시작 | 새 템플릿 생성 진입 | `template_origin`(`default`\|`empty`) | `TemplateListPage.tsx · handleCreateFromDefault`, `handleCreateEmpty` | - | +| `MP_template_delete` | 템플릿 삭제 | 템플릿 삭제 파악 | `template_id`, `template_origin`, `sync_status` | `TemplateListPage.tsx · handleDeleteTemplate` | - | +| `MP_template_likeToggle` | 좋아요 토글 | 좋아요 사용 파악 | `posted_template_id`, `is_liked` | `GalleryPage.tsx · handleLike` | - | +| `MP_templateClone_fail` | 복제 실패 | 복제 실패 파악 | `posted_template_id`, `error_code`, `error_message?` | `GalleryPage.tsx · handleClone catch` | - | +| `MP_templateClone_success` | 복제 성공 | 공개 템플릿 복제 성공 | `posted_template_id`, `is_author_id_present` | `GalleryPage.tsx · handleClone` | - | +| `MP_templateEditor_view` | 에디터 진입 | 에디터 진입률 측정 | `template_origin`, `template_id?` | `EditorPage.tsx · EditorContent useEffect` | 기존 템플릿은 로드된 `template.cloned` 값으로 `owned`/`cloned` 구분 | +| `MP_templateGallery_search` | 갤러리 검색/정렬 | 갤러리 검색 및 정렬 사용률 | `query_length`, `sort_option` | `GalleryPage.tsx · debounce useEffect([searchQuery, sort])` | 검색어가 없어도 정렬 변경 시 `query_length=0`으로 전송 | +| `MP_templateGallery_view` | 갤러리 진입 | 갤러리 진입 파악 | `entry_point` | `GalleryPage.tsx · useEffect([])` | - | +| `MP_templateItem_add` | 아이템 추가 | 에디터 핵심 편집 행위 | `add_method`(`drag`\|`button`), `template_id?` | `EditorPage.tsx · handleDragEnd`, `ItemPropertiesPanel.tsx · handleMoveToCanvas` | - | +| `MP_templateItem_delete` | 아이템 삭제 | 아이템 삭제 행위 | `delete_source`(`canvas`\|`staging`), `template_id?` | `ItemPropertiesPanel.tsx · handleDelete` | `canvas`: 임시저장 이동, `staging`: 영구삭제 | +| `MP_templateItem_update` | 아이템 속성 수정 | 아이템 속성 수정 측정 | `update_type`, `template_id?` | `ItemPropertiesPanel.tsx · handleSave` | `update_type` 항상 `"properties"` — 향후 세분화 시 개선 가능 | +| `MP_templatePublish_fail` | 게시 실패 | 게시 실패 파악 | `template_id`, `error_code`, `error_message` | `EditorHeader.tsx · handlePublish` | - | +| `MP_templatePublish_success` | 게시 성공 | 갤러리 게시 성공 | `template_id`, `item_count` | `EditorHeader.tsx · handlePublish` | - | +| `MP_templateSave_fail` | 저장 실패 | 저장 실패 파악 | `template_id`, `error_code`, `error_message` | `EditorHeader.tsx · handleSave` | draft 저장 실패 시 `template_id=0` 전송 (ID 생성 전) | +| `MP_templateSave_success` | 저장 성공 | 로컬 저장 완료 | `template_id`, `template_origin`, `item_count` | `EditorHeader.tsx · handleSave` | - | +| `MP_templateSync_fail` | 동기화 실패 | 동기화 실패 파악 | `template_id`, `error_code`, `error_message` | `EditorHeader.tsx · handleSyncToServer` | - | +| `MP_templateSync_success` | 동기화 성공 | 서버 동기화 성공 | `template_id`, `item_count` | `EditorHeader.tsx · handleSyncToServer` | - | +| `MP_todoItem_complete` | Todo 완료 체크 | Todo 완료율 측정 | `item_type` | `TodoList.tsx · handleToggleTodo` | `custom` 타입만 전송 (eCampus todo는 완료 토글 UI 없음) | +| `MP_todoItem_create` | Todo 추가 | Todo 입력 파악 | `source`, `has_due_date` | `TodoAddDialog.tsx · handleSubmit` | - | +| `MP_todoItem_delete` | Todo 삭제 | Todo 삭제 파악 | `item_type` | `TodoList.tsx · handleDeleteTodo` | `custom` 타입만 전송 (eCampus todo는 삭제 UI 없음) | +| `MP_todo_view` | Todo 탭 진입 | Todo 기능 사용 여부 | `todo_count` | `TodoList.tsx · useEffect([isLoading, ...])` | `viewOpenSentRef`로 최초 1회 보장 | diff --git a/src/App.tsx b/src/App.tsx index 4da3796..8fbe3b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,23 +8,28 @@ import { Outlet } from "react-router-dom"; import { ErrorBoundary } from "react-error-boundary"; import { Toaster } from "./components/ui/sonner"; import { PostedTemplatesProvider } from "./contexts/PostedTemplatesContext"; -import { sendPageView } from "./utils/analytics"; +import { sendExtensionOpen, sendPageView, sendError } from "./utils/analytics"; import { debugLog } from "@/utils/logger"; import "./App.css"; function App() { - // Google Analytics: Extension 열릴 때 페이지뷰 전송 + // GA4: popup mount 시 first_open / session_start / extension_open 자동 전송 useEffect(() => { debugLog( "%c여길 열어보시다니...\n이 참에 직접 코드 기여도 해주시는 건 어떤가요?", "font-family: Nanum Gothic; color: darkgreen; padding: 6px; border-radius: 4px; font-size:14px", ); debugLog("https://github.com/Turtle-Hwan/LinKU"); - sendPageView("LinKU Extension - Popup", "chrome-extension://linku/popup"); + sendExtensionOpen("popup_home", "popup"); + sendPageView("LinKU Extension - Popup"); }, []); return ( { + const msg = error instanceof Error ? error.message : String(error); + sendError("react_error_boundary", msg, "popup_home"); + }} fallback={
diff --git a/src/components/Editor/EditorHeader/EditorHeader.tsx b/src/components/Editor/EditorHeader/EditorHeader.tsx index 15d07eb..1a259ea 100644 --- a/src/components/Editor/EditorHeader/EditorHeader.tsx +++ b/src/components/Editor/EditorHeader/EditorHeader.tsx @@ -19,13 +19,20 @@ import { import { getTemplate } from '@/apis/templates'; import { areTemplatesEqual } from '@/utils/templateUtils'; import { debugLog, errorLog } from '@/utils/logger'; +import { + sendTemplateSaveSuccess, + sendTemplateSaveFail, + sendTemplateSyncSuccess, + sendTemplateSyncFail, + sendTemplatePublishSuccess, + sendTemplatePublishFail, +} from '@/utils/analytics'; export const EditorHeader = () => { const { state, dispatch } = useEditorContext(); const { syncToServer } = useTemplateSync(); const { publishTemplate } = useTemplatePublish(); const { loadPostedTemplates } = usePostedTemplates(); - const handleNameChange = (e: React.ChangeEvent) => { dispatch({ type: 'UPDATE_TEMPLATE_NAME', payload: e.target.value }); }; @@ -100,24 +107,18 @@ export const EditorHeader = () => { dispatch({ type: 'SAVE_SUCCESS', payload: savedTemplate }); + const origin = savedTemplate.cloned ? 'cloned' : 'owned'; + sendTemplateSaveSuccess(savedTemplate.templateId, origin, savedTemplate.items.length); + toast.success('저장 완료', { description: '템플릿이 로컬에 저장되었습니다.', }); } catch (error) { errorLog('[EditorHeader] Save failed:', error); - dispatch({ - type: 'SAVE_FAILED', - payload: - error instanceof Error - ? error.message - : '저장 중 오류가 발생했습니다.', - }); - toast.error('저장 실패', { - description: - error instanceof Error - ? error.message - : '저장 중 오류가 발생했습니다.', - }); + const errMsg = error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.'; + dispatch({ type: 'SAVE_FAILED', payload: errMsg }); + sendTemplateSaveFail(state.template.templateId, 'save_error', errMsg); + toast.error('저장 실패', { description: errMsg }); } }; @@ -145,15 +146,18 @@ export const EditorHeader = () => { if (result.success && result.data) { dispatch({ type: 'SYNC_SUCCESS', payload: result.data }); + sendTemplateSyncSuccess( + state.template.templateId, + result.data.items?.length ?? state.template.items.length + ); toast.success('동기화 완료', { description: '템플릿이 서버에 동기화되었습니다.', }); } else { const errorMsg = result.error || '동기화에 실패했습니다.'; dispatch({ type: 'SYNC_FAILED', payload: errorMsg }); - toast.error('동기화 실패', { - description: errorMsg, - }); + sendTemplateSyncFail(state.template.templateId, 'sync_failed', errorMsg); + toast.error('동기화 실패', { description: errorMsg }); } }; @@ -170,13 +174,14 @@ export const EditorHeader = () => { if (result.success) { await loadPostedTemplates(); + sendTemplatePublishSuccess(state.template.templateId, currentItems.length); toast.success('게시 완료', { description: '템플릿이 공개 갤러리에 게시되었습니다.', }); } else { - toast.error('게시 실패', { - description: result.error || '게시에 실패했습니다.', - }); + const errMsg = result.error || '게시에 실패했습니다.'; + sendTemplatePublishFail(state.template.templateId, 'publish_failed', errMsg); + toast.error('게시 실패', { description: errMsg }); } }; diff --git a/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx b/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx index 794ebcb..babf93b 100644 --- a/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx +++ b/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx @@ -16,6 +16,7 @@ import { validateLinkForm } from '@/utils/formValidation'; import { IconGrid } from '@/components/Editor/shared/IconGrid'; import type { TemplateIcon, TemplateItem } from '@/types/api'; import { InputGroup } from '@/components/Editor/shared/InputGroup'; +import { sendTemplateItemAdd, sendTemplateItemUpdate, sendTemplateItemDelete } from '@/utils/analytics'; export const ItemPropertiesPanel = () => { const { state, dispatch } = useEditorContext(); @@ -48,6 +49,7 @@ export const ItemPropertiesPanel = () => { key={selectedItem.templateItemId} selectedItem={selectedItem} isFromStaging={isFromStaging} + templateId={state.template?.templateId} defaultIcons={state.defaultIcons} userIcons={state.userIcons} dispatch={dispatch} @@ -58,6 +60,7 @@ export const ItemPropertiesPanel = () => { interface ItemPropertiesPanelFormProps { selectedItem: TemplateItem; isFromStaging: boolean; + templateId: number | undefined; defaultIcons: ReturnType['state']['defaultIcons']; userIcons: ReturnType['state']['userIcons']; dispatch: ReturnType['dispatch']; @@ -66,6 +69,7 @@ interface ItemPropertiesPanelFormProps { const ItemPropertiesPanelForm = ({ selectedItem, isFromStaging, + templateId, defaultIcons, userIcons, dispatch, @@ -138,6 +142,7 @@ const ItemPropertiesPanelForm = ({ }, }); + sendTemplateItemUpdate('properties', templateId); toast.success('변경사항이 저장되었습니다.'); }; @@ -145,10 +150,12 @@ const ItemPropertiesPanelForm = ({ if (isFromStaging) { // Permanently delete from staging dispatch({ type: 'REMOVE_FROM_STAGING', payload: selectedItem.templateItemId }); + sendTemplateItemDelete('staging', templateId); toast.info('아이템이 영구 삭제되었습니다.'); } else { // Move canvas item to staging dispatch({ type: 'MOVE_TO_STAGING', payload: selectedItem.templateItemId }); + sendTemplateItemDelete('canvas', templateId); toast.info('아이템이 임시 저장 공간으로 이동되었습니다.'); } }; @@ -156,6 +163,7 @@ const ItemPropertiesPanelForm = ({ const handleMoveToCanvas = () => { if (!isFromStaging) return; dispatch({ type: 'MOVE_TO_CANVAS', payload: selectedItem.templateItemId }); + sendTemplateItemAdd('button', templateId); toast.success('아이템이 캔버스에 추가되었습니다.'); }; diff --git a/src/components/EmailVerificationDialog.tsx b/src/components/EmailVerificationDialog.tsx index 805da7d..13a0aa5 100644 --- a/src/components/EmailVerificationDialog.tsx +++ b/src/components/EmailVerificationDialog.tsx @@ -3,7 +3,7 @@ * 건국대 이메일 인증 다이얼로그 */ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { toast } from 'sonner'; import { Mail, ArrowLeft, Loader2 } from 'lucide-react'; import { @@ -22,6 +22,7 @@ import { validateAuthCode, } from '@/utils/formValidation'; import { errorLog } from '@/utils/logger'; +import { sendAuthEmailVerificationStart, sendAuthEmailVerificationSuccess } from '@/utils/analytics'; interface EmailVerificationDialogProps { open: boolean; @@ -43,6 +44,11 @@ export function EmailVerificationDialog({ const [authCode, setAuthCode] = useState(''); const [isLoading, setIsLoading] = useState(false); + // 다이얼로그가 열릴 때 인증 시작 이벤트 전송 + useEffect(() => { + if (open) sendAuthEmailVerificationStart('settings_dialog'); + }, [open]); + // Full email address const kuMail = emailId ? `${emailId}${EMAIL_DOMAIN}` : ''; @@ -102,6 +108,7 @@ export function EmailVerificationDialog({ toast.success('이메일 인증이 완료되었습니다!'); // Store verified email await chrome.storage.local.set({ kuMail }); + sendAuthEmailVerificationSuccess('konkuk.ac.kr'); // Trigger re-login to get member token onVerificationComplete(); handleClose(); diff --git a/src/components/Labs/LibrarySeatSection.tsx b/src/components/Labs/LibrarySeatSection.tsx index d146da5..2fa6e02 100644 --- a/src/components/Labs/LibrarySeatSection.tsx +++ b/src/components/Labs/LibrarySeatSection.tsx @@ -10,6 +10,7 @@ import { } from '@/apis'; import { LibrarySeatRoom } from '@/types/api'; import { loadECampusCredentials } from '@/utils/credentials'; +import { sendLabsFeatureUse } from '@/utils/analytics'; const LibrarySeatSection = () => { const [rooms, setRooms] = useState([]); @@ -75,6 +76,7 @@ const LibrarySeatSection = () => { }, [fetchSeatRooms]); const handleOpenRoom = (roomId: number) => { + sendLabsFeatureUse('library_seat', 'success'); openLibraryReservationPage(roomId); }; diff --git a/src/components/Labs/QRGeneratorSection.tsx b/src/components/Labs/QRGeneratorSection.tsx index 83d2194..54f9334 100644 --- a/src/components/Labs/QRGeneratorSection.tsx +++ b/src/components/Labs/QRGeneratorSection.tsx @@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label"; import { Info, Download, Check, Upload, X } from "lucide-react"; import QRCode from "qrcode"; import { warnLog } from '@/utils/logger'; +import { sendLabsFeatureUse } from '@/utils/analytics'; // LinKU 로고 (public/assets/icon128.png) - 고해상도 사용 const LINKU_LOGO_URL = "/assets/icon128.png"; @@ -120,9 +121,11 @@ const QRGeneratorSection = () => { const dataUrl = await generateQRWithLogo(activeUrl, logoSrc); setQrDataUrl(dataUrl); setError(""); + sendLabsFeatureUse('qr_generator', 'success'); } catch { setError("QR 코드 생성에 실패했습니다"); setQrDataUrl(""); + sendLabsFeatureUse('qr_generator', 'fail'); } finally { setIsGenerating(false); } diff --git a/src/components/LabsDialog.tsx b/src/components/LabsDialog.tsx index 803e55c..ed49f11 100644 --- a/src/components/LabsDialog.tsx +++ b/src/components/LabsDialog.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Dialog, DialogContent, @@ -9,6 +10,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import ServerClockSection from "./Labs/ServerClockSection"; import QRGeneratorSection from "./Labs/QRGeneratorSection"; import LibrarySeatSection from "./Labs/LibrarySeatSection"; +import { sendLabsOpen } from "@/utils/analytics"; interface LabsDialogProps { open: boolean; @@ -16,6 +18,10 @@ interface LabsDialogProps { } const LabsDialog = ({ open, onOpenChange }: LabsDialogProps) => { + useEffect(() => { + if (open) sendLabsOpen(); + }, [open]); + return ( diff --git a/src/components/MainLayout.tsx b/src/components/MainLayout.tsx index 8807b01..0ec4df4 100644 --- a/src/components/MainLayout.tsx +++ b/src/components/MainLayout.tsx @@ -6,7 +6,7 @@ import { Input } from "./ui/input"; import { Search, Settings, FlaskConical } from "lucide-react"; import SettingsDialog from "./SettingsDialog"; import LabsDialog from "./LabsDialog"; -import { sendButtonClick, sendGAEvent } from "@/utils/analytics"; +import { sendButtonClick, sendSearchSubmit } from "@/utils/analytics"; const MainLayout = () => { return ( @@ -41,10 +41,7 @@ const Header = () => { onChange={(e) => setText((e.target as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter") { - sendGAEvent("search", { - search_term: text, - search_location: "header" - }); + sendSearchSubmit(text, "header"); window.open( `https://search.konkuk.ac.kr/main.do?keyword=${text}` ); @@ -56,10 +53,7 @@ const Header = () => {
{ - sendButtonClick("labs_icon", "header"); - setShowLabs(true); - }} + onClick={() => setShowLabs(true)} /> { await saveECampusCredentials(savedId, savedPassword); setHasCredentials(true); - sendSettingChange("credentials", "saved"); + sendSettingsCredentialsSaved(); toast.success("인증 정보가 저장되었습니다."); // 2. 로그인 검증 (백그라운드) @@ -110,7 +118,7 @@ const ECampusCredential = () => { setSavedId(""); setSavedPassword(""); setHasCredentials(false); - sendSettingChange("credentials", "deleted"); + sendSettingsCredentialsDeleted(); toast.success("인증 정보가 삭제되었습니다."); } catch (error) { errorLog("[Settings] Delete credentials error:", error); @@ -243,7 +251,7 @@ const GoogleOAuthSection = () => { const handleGoogleLogin = async () => { setIsLoading(true); - sendButtonClick("google_login", "settings_dialog"); + sendAuthLoginStart("google", "settings_dialog"); try { const result = await startGoogleLogin(); @@ -254,21 +262,26 @@ const GoogleOAuthSection = () => { // Check if this is a guest (requires signup) if (result.response.requiresSignup) { setIsGuest(true); + sendAuthLoginSuccess("google", true); // Auto-open email verification dialog for guests setShowEmailVerification(true); toast.info("건국대 이메일 인증이 필요합니다."); } else { setIsGuest(false); setUserProfile(result.response.profile); + sendAuthLoginSuccess("google", false); toast.success("로그인 성공!"); } } else { + sendAuthLoginFail("google", "login_failed", result.error || "알 수 없는 오류"); toast.error("로그인 실패", { description: result.error, }); } } catch (error) { errorLog("Login error:", error); + const errMsg = error instanceof Error ? error.message : "로그인 중 오류가 발생했습니다."; + sendAuthLoginFail("google", "exception", errMsg); toast.error("오류", { description: "로그인 중 오류가 발생했습니다.", }); @@ -310,7 +323,7 @@ const GoogleOAuthSection = () => { }; const handleLogout = async () => { - sendButtonClick("google_logout", "settings_dialog"); + sendAuthLogout("settings_dialog"); await logout(); setLoggedIn(false); @@ -441,8 +454,6 @@ const GoogleOAuthSection = () => { const TemplateEditorSection = () => { const handleOpenEditor = () => { - sendButtonClick("open_template_editor", "settings_dialog"); - // 새 탭에서 템플릿 에디터 열기 chrome.tabs.create({ url: chrome.runtime.getURL('index.html#/editor') diff --git a/src/components/Tabs/Alerts/AlertItem.tsx b/src/components/Tabs/Alerts/AlertItem.tsx index df8b9c1..be6a8b3 100644 --- a/src/components/Tabs/Alerts/AlertItem.tsx +++ b/src/components/Tabs/Alerts/AlertItem.tsx @@ -1,6 +1,7 @@ import type { Alert, AlertCategory } from "@/types/api"; import { ExternalLink, Calendar } from "lucide-react"; import { cn } from "@/lib/utils"; +import { sendAlertsItemOpen } from "@/utils/analytics"; interface AlertItemProps { alert: Alert; @@ -39,9 +40,17 @@ const AlertItem = ({ alert }: AlertItemProps) => { }; const handleClick = () => { - if (alert.url) { - window.open(alert.url, "_blank"); - } + if (!alert.url) return; + // category 필드가 표준 카테고리 외 값(학과명)이면 학과 공지로 분류 + const isDept = + "department" in alert || + ("category" in alert && !standardCategories.has(alert.category)); + const source = isDept ? "department" : "general"; + const category = isDept + ? ("category" in alert ? String(alert.category) : alert.department.name) + : String(alert.category); + sendAlertsItemOpen(alert.alertId, category, source); + window.open(alert.url, "_blank"); }; const isClickable = Boolean(alert.url); diff --git a/src/components/Tabs/Alerts/Alerts.tsx b/src/components/Tabs/Alerts/Alerts.tsx index aa0a95b..3adde02 100644 --- a/src/components/Tabs/Alerts/Alerts.tsx +++ b/src/components/Tabs/Alerts/Alerts.tsx @@ -9,6 +9,7 @@ import AlertFilter from "./AlertFilter"; import MyAlertsView from "./MyAlertsView"; import { Badge } from "@/components/ui/badge"; import { errorLog } from '@/utils/logger'; +import { sendAlertsView } from '@/utils/analytics'; type AlertViewMode = "all" | "my"; @@ -85,6 +86,10 @@ const Alerts = () => { } setIsInitialized(true); + + const resolvedViewMode = (savedViewMode === "my" && !loginStatus) ? "all" : (savedViewMode || "all"); + const resolvedCategory = savedCategory || "전체"; + sendAlertsView(resolvedViewMode, resolvedCategory); }; initialize(); }, []); diff --git a/src/components/Tabs/Alerts/SubscriptionManager.tsx b/src/components/Tabs/Alerts/SubscriptionManager.tsx index eefe5f8..cf7466d 100644 --- a/src/components/Tabs/Alerts/SubscriptionManager.tsx +++ b/src/components/Tabs/Alerts/SubscriptionManager.tsx @@ -9,6 +9,7 @@ import type { Department, Subscription } from "@/types/api"; import { Check, Plus, X } from "lucide-react"; import { toast } from "sonner"; import { errorLog } from '@/utils/logger'; +import { sendAlertsSubscriptionChange } from '@/utils/analytics'; interface SubscriptionManagerProps { onUpdate?: () => void; @@ -61,6 +62,7 @@ const SubscriptionManager = ({ onUpdate }: SubscriptionManagerProps) => { const result = await subscribeDepartment(departmentId); if (result.success) { + sendAlertsSubscriptionChange(departmentName, 'subscribe'); toast.success(`${departmentName} 구독이 완료되었습니다.`); await fetchData(); onUpdate?.(); @@ -79,6 +81,7 @@ const SubscriptionManager = ({ onUpdate }: SubscriptionManagerProps) => { const result = await unsubscribeDepartment(departmentId); if (result.success) { + sendAlertsSubscriptionChange(departmentName, 'unsubscribe'); toast.success(`${departmentName} 구독이 취소되었습니다.`); await fetchData(); onUpdate?.(); diff --git a/src/components/Tabs/ImageCarousel.tsx b/src/components/Tabs/ImageCarousel.tsx index 7748499..a9a8177 100644 --- a/src/components/Tabs/ImageCarousel.tsx +++ b/src/components/Tabs/ImageCarousel.tsx @@ -4,6 +4,7 @@ import Autoplay from "embla-carousel-autoplay"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { BannerItemType, getBannersAPI } from "@/apis"; import { IMAGE_URL } from "@/constants/URL"; +import { sendBannerOpen } from "@/utils/analytics"; const bannerPromise = getBannersAPI(); @@ -48,7 +49,7 @@ const ImageCarousel = () => {
{imageList.map((item, idx) => ( - + ))}
@@ -83,13 +84,16 @@ const ImageCarousel = () => { ); }; -const Image = ({ item }: { item: BannerItemType }) => { +const Image = ({ item, position }: { item: BannerItemType; position: number }) => { return (
{item.alt} window.open(item.link)} + onClick={() => { + sendBannerOpen(item.img, item.alt, position); + window.open(item.link); + }} className="w-full h-full object-cover cursor-pointer" />
diff --git a/src/components/Tabs/LinkGroup.tsx b/src/components/Tabs/LinkGroup.tsx index 61479f4..8408469 100644 --- a/src/components/Tabs/LinkGroup.tsx +++ b/src/components/Tabs/LinkGroup.tsx @@ -72,7 +72,7 @@ const GridItemSameHost = ({ item, colNum }) => {