diff --git a/.gitignore b/.gitignore index 218c29a34..a23acf185 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ dailyNote/ +/.omc +.gstack/ diff --git a/docs/weekly-reports/2026-01-01-to-03-04-club-engagement-percentile-analysis.md b/docs/weekly-reports/2026-01-01-to-03-04-club-engagement-percentile-analysis.md new file mode 100644 index 000000000..d6065f2b5 --- /dev/null +++ b/docs/weekly-reports/2026-01-01-to-03-04-club-engagement-percentile-analysis.md @@ -0,0 +1,211 @@ +# 동아리 상세페이지 체류시간 퍼센타일 분석 (P90 / P99) + +**분석 기간**: 2026-01-01 ~ 2026-03-04 +**분석 일자**: 2026-04-21 +**데이터 소스**: Mixpanel + +> ⚠️ Mixpanel은 P90 / P99만 지원합니다. P95는 제공되지 않습니다. +> +> **노이즈 제외 항목**: `undefined`, `테스트가즈아`, `흑백요리테스트`, `스타피쉬입니다다`, `스타피쉬농구입니다`, `스타피쉬`, `WAP(금일 18시 마감)` (2026-02-18 하루 임시 클럽명 변경, 100건) +> +> ℹ️ **제외 범위**: `WAP(금일 18시 마감)`은 정식 클럽 `WAP`이 2026-02-18 하루 동안 임시 변경한 클럽명으로, 해당 날짜의 100건만 전체 분석에서 제외합니다. 정식명 `WAP`의 나머지 기간 데이터는 모든 분석(P90·P99·중간값·구간별 클릭률)에 포함됩니다. + +--- + +## 📊 분석 1: 전체 체류시간 분포 + +### 전체 분포 요약 + +| 지표 | 값 (초) | 해석 | +| ---------------- | ------- | --------------------------------------- | +| **P25** | 3 | 하위 25% 유저는 3초 이내에 이탈 | +| **P50 (중간값)** | 7 | 절반의 유저가 7초 이내 체류 | +| **P75** | 17 | 상위 25% 유저는 17초 이상 체류 | +| **P90** | 30 | 상위 10% 유저는 30초 이상 체류 | +| **P99** | 90 | 상위 1% 유저는 90초(약 1.5분) 이상 체류 | + +### 인사이트 + +- **중간값 7초 vs P90 30초**: P90이 중간값의 4.3배로, 체류시간 분포가 **오른쪽으로 크게 치우쳐** 있음 (롱테일 분포) +- 전체 유저의 **75%가 17초 이내에 떠남** — 상세페이지가 스캔형 소비 패턴 위주임 +- P90~P99 구간(30초~90초)의 유저는 **깊이 있는 탐색형 유저**로, 지원 전환율이 높을 가능성이 큼 + +### 리포트 링크 + +[Mixpanel 전체 분포 리포트](https://mixpanel.com/project/3611536/view/4111120/app/insights#Hy5LcgeewevP) + +--- + +## 📊 분석 2: P90 기준 동아리 TOP10 + +P90 = "상위 10%의 체류시간" — 해당 동아리의 가장 관심도 높은 유저층의 체류 수준 + +### TOP10 동아리 (P90 기준) + +| 순위 | 동아리명 | P90 (초) | 중간값 (초) | P90 / 중간값 | +| ---- | -------------------- | -------- | ----------- | ------------ | +| 1 | O.S.T | 54 | 9 | 6.0x | +| 2 | 집현전 | 52 | 12 | 4.3x | +| 3 | 부경다이버 | 49 | 11 | 4.5x | +| 4 | 스타피쉬-축구 | 45 | 11 | 4.1x | +| 5 | 백경클래식기타연구회 | 44 | 10 | 4.4x | +| 5 | 돼지 | 44 | 10 | 4.4x | +| 7 | TIME | 42 | 10 | 4.2x | +| 7 | 차사랑 | 42 | 13 | 3.2x | +| 7 | 프라우드 | 42 | 8 | 5.3x | +| 10 | 홍백 | 41 | 9 | 4.6x | +| 10 | UCDC | 41 | 8 | 5.1x | +| 10 | 보블리스 | 41 | 7 | 5.9x | + +### 인사이트 + +- **O.S.T, 보블리스, 프라우드**: P90 / 중간값 비율 5배 이상 → 소수의 슈퍼 탐색 유저가 롱테일을 만듦. 대부분은 빠르게 떠나지만 관심 있는 유저는 매우 오래 체류 +- **차사랑**: 중간값 13초(전체 최고)에 P90 42초 → **평균적으로도, 상위권도 모두 높은** 고른 고관심 동아리 +- **집현전**: 중간값 12초 + P90 52초 → 전반적으로 탐색 시간이 긴 편 + +### 리포트 링크 + +[Mixpanel P90 동아리별 리포트](https://mixpanel.com/project/3611536/view/4111120/app/insights#DeBkwEACT34e) + +--- + +## 📊 분석 3: P99 기준 동아리 TOP10 + +P99 = "상위 1%의 체류시간" — 극단적 관심 유저 또는 이상치(outlier) 포함 가능 + +### TOP10 동아리 (P99 기준) + +| 순위 | 동아리명 | P99 (초) | P90 (초) | P99 / P90 | 비고 | +| ---- | ------------- | -------- | -------- | --------- | --------------------- | +| 1 | O.S.T | 389 | 54 | 7.2x | 극단적 탐색 유저 존재 | +| 2 | WAP | 190 | 35 | 5.4x | | +| 3 | TIME | 156 | 42 | 3.7x | | +| 4 | CCC | 143 | 28 | 5.1x | | +| 5 | 스타피쉬-축구 | 133 | 45 | 3.0x | | +| 5 | 어택 | 133 | 34 | 3.9x | | +| 7 | 보블리스 | 130 | 41 | 3.2x | | +| 8 | 집현전 | 123 | 52 | 2.4x | | +| 9 | 후라 | 108 | 33 | 3.3x | | +| 9 | 부경다이버 | 108 | 49 | 2.2x | | + +### P99 / P90 비율로 보는 패턴 + +| 패턴 | 동아리 | 특징 | +| --------------- | --------------------------------- | --------------------------------------------- | +| **롱테일 심각** | O.S.T, WAP, CCC | P99가 P90의 5배 이상 → 극단 유저가 평균 왜곡 | +| **균일 고관심** | 집현전, 부경다이버, 스타피쉬-축구 | P99 / P90 비율 낮음 → 상위 유저층이 고른 분포 | + +### 리포트 링크 + +[Mixpanel P99 동아리별 리포트](https://mixpanel.com/project/3611536/view/4111120/app/insights#zpnzHHUgaojL) + +--- + +## 📊 분석 4: 중간값 + P90 + P99 종합 비교 + +중간값이 높으면서 P90도 높은 동아리 = **전반적으로 높은 관심도** + +| 동아리명 | 중간값 (초) | P90 (초) | P99 (초) | 종합 평가 | +| ------------------------ | ----------- | -------- | -------- | ---------------------------------- | +| **차사랑** | 13 | 42 | 83 | ⭐ 전 구간 고른 고관심 | +| **집현전** | 12 | 52 | 123 | ⭐ 전 구간 고른 고관심 | +| **부경다이버** | 11 | 49 | 108 | ⭐ 전 구간 고른 고관심 | +| **스타피쉬-축구** | 11 | 45 | 133 | 전 구간 고관심 | +| **스타피쉬-농구** | 11 | 40 | 70 | 전 구간 고관심 | +| **TIME** | 10 | 42 | 156 | P99 극단치 존재 | +| **돼지** | 10 | 44 | 101 | 고관심 + 롱테일 | +| **백경클래식기타연구회** | 10 | 44 | 85 | 고관심 | +| **O.S.T** | 9 | 54 | 389 | 중간값 낮지만 P90↑, 극단 유저 다수 | +| **보블리스** | 7 | 41 | 130 | 중간값 낮지만 롱테일 강함 | + +--- + +## 📊 분석 5: 체류시간 구간별 지원 클릭률 + +유저별 평균 체류시간을 기준으로 3구간으로 나눠 `Club Apply Button Clicked` 전환율 비교 + +### 구간 정의 + +- **단기**: 평균 체류시간 [0, 10)초 — 0초 이상 10초 미만 (P50 이하 근사) +- **중기**: 평균 체류시간 [10, 30)초 — 10초 이상 30초 미만 (P50~P90) +- **장기**: 평균 체류시간 [30, ∞)초 — 30초 이상 (P90 이상) + +### 결과 + +| 구간 | 방문 유저 | 지원 클릭 유저 | 클릭률 | +| ------------------ | --------- | -------------- | ---------------- | +| **단기 (0~10초)** | 1,545명 | 626명 | **40.5%** | +| **중기 (10~30초)** | 2,423명 | 1,200명 | **49.5%** ← 최고 | +| **장기 (30초+)** | 373명 | 116명 | **31.1%** | +| **전체** | 4,341명 | 1,947명 | **44.8%** | + +### 핵심 인사이트 + +#### 예상과 반대 결과 ⚠️ + +**체류시간이 길수록 지원율이 높지 않고, 중간 구간(10~30초)이 가장 높음** + +#### 구간별 해석 + +| 구간 | 가설 (인과 미확정, 관찰 기반) | +| ------------------ | -------------------------------------------------------------------------------------- | +| **단기 (~10초)** | 스캔 후 이탈, 또는 외부 경로로 이미 정보를 얻고 빠르게 확인만 했을 가능성 → 낮은 전환 | +| **중기 (10~30초)** | 충분한 정보 탐색 후 결정했을 가능성 → **최적 전환 구간으로 추정** | +| **장기 (30초+)** | 정보 부족·의사결정 지연·단순 페이지 방치 등 복수 원인 가능성 → 낮은 전환 (원인 미확정) | + +> ⚠️ 위 해석은 상관관계 기반 가설입니다. 인과 확정을 위해 아래 검증 계획을 권장합니다. +> +> **검증 계획** +> +> 1. **CTA 노출 실험**: 30초 이상 체류 유저에게 sticky 지원하기 버튼 노출 → 클릭률 변화 측정 +> 2. **스크롤 깊이 이벤트 추가**: 장기 체류 유저가 실제로 콘텐츠를 읽는지, 페이지를 방치했는지 구분 +> 3. **재방문 패턴 분석**: 장기 체류 후 이탈한 유저가 추후 재방문·지원하는지 추적 + +#### 비즈니스 시사점 + +- **상세페이지 체류시간 10~30초가 골든 존으로 추정** — 이 구간 유저를 늘리는 것이 지원율 향상에 효과적일 가능성 +- **30초 이상 체류 유저는 전환이 낮음** — 정보 충분성·CTA 가시성·페이지 방치 등 원인이 복수일 수 있어 실험 전 단정 지양 +- W14 리포트의 "지원 클릭 유저가 비클릭 유저보다 체류시간이 짧다"는 발견과 일치하는 패턴 + +### 리포트 링크 + +[Mixpanel 구간별 클릭률 리포트](https://mixpanel.com/project/3611536/view/4111120/app/insights#FH82jUQwhvE7) + +--- + +## 📌 다음 분석 제안 + +1. **차사랑 / 집현전 지원 전환율 분석** + - 체류시간 전 구간에서 높은 두 동아리의 실제 지원 클릭률 확인 + +2. **O.S.T 극단 유저 세그먼트 분석** + - P90 54초 / P99 389초 → 롱테일 유저가 누구인지, 지원으로 이어졌는지 분석 + +3. **WAP 이상치 원인 조사** + - `WAP(금일 18시 마감)` 771초 이상치 — 실제 유저인지 노이즈인지 확인 + +4. **P90 유저 지원 전환율 비교** + - P90 이상 체류 유저 vs 전체 유저의 `Club Apply Button Clicked` 비율 비교 + +5. **테스트 동아리 DB 정리** + - `테스트가즈아`, `흑백요리테스트` 등 테스트성 항목이 운영 데이터에 포함됨 — 백엔드에서 확인 및 제거 필요 + +--- + +## 🚀 액션 아이템 + +### 개발 + +1. **상세페이지 장기 체류 유저 CTA 강화** + - 30초+ 체류 유저의 전환율이 31.1%로 가장 낮음 + - 스크롤 일정 이상 시 지원하기 버튼 sticky 노출 또는 CTA 위치 개선 검토 + +2. **동아리 상세페이지 정보 구조 개선** + - 장기 체류 = 필요한 정보를 찾지 못하고 있을 가능성 + - 핵심 정보(모집 일정, 지원 자격, 활동 내용) 상단 배치 및 가독성 개선 검토 + +### 데이터/운영 + +3. **테스트 동아리 DB 정리** + - `테스트가즈아`, `흑백요리테스트` 운영 DB에서 제거 요청 (백엔드) + - 클럽명 임시 변경 관행 개선 — `WAP(금일 18시 마감)` 같은 케이스 방지 diff --git a/docs/weekly-reports/2026-01-to-03-club-engagement-analysis.md b/docs/weekly-reports/2026-01-to-03-club-engagement-analysis.md new file mode 100644 index 000000000..e37de3337 --- /dev/null +++ b/docs/weekly-reports/2026-01-to-03-club-engagement-analysis.md @@ -0,0 +1,279 @@ +# 동아리 상세페이지 체류시간 및 지원 행동 분석 (1-3월) + +**분석 기간**: 2026-01-01 ~ 2026-03-04 +**분석 일자**: 2026-04-04 +**데이터 소스**: Mixpanel + +--- + +## 📊 분석 1: 상세페이지 체류시간 TOP10 동아리 + +### 주요 지표 + +- **측정 이벤트**: `ClubDetailPage Duration` +- **측정 지표**: 체류시간 중간값 (`duration_seconds`) +- **분석 방법**: 동아리별 체류시간 중간값 집계 + +### TOP10 동아리 + +| 순위 | 동아리명 | 중간값 (초) | +| ---- | -------------------- | ----------- | +| 1 | 차사랑 | 13 | +| 2 | 집현전 | 12 | +| 3 | 스타피쉬-농구 | 11 | +| 3 | 스타피쉬-축구 | 11 | +| 3 | 부경다이버 | 11 | +| 6 | TIME | 10 | +| 6 | 백경클래식기타연구회 | 10 | +| 6 | 돼지 | 10 | +| 9 | PKNUO | 9 | +| 9 | 요트제작연구회 | 9 | +| 9 | 절영회 | 9 | +| 9 | 백경 유스호스텔 | 9 | +| 9 | 아카데미 | 9 | +| 9 | O.S.T | 9 | +| 9 | 백경극예술연구회 | 9 | +| 9 | 홍백 | 9 | +| 9 | 한누리 | 9 | +| 9 | 수석회 | 9 | +| 9 | 디그 | 9 | + +### 인사이트 + +- **차사랑**이 13초로 1위 기록 +- **집현전**이 12초로 2위 (장기간 안정적으로 높은 체류시간 유지) +- 스타피쉬 스포츠 동아리들(농구, 축구)이 11초로 공동 3위 +- 전체 중간값은 7초 +- 9초로 동점인 동아리가 10개로 매우 많음 (9위권 경쟁 치열) + +### 리포트 링크 + +[Mixpanel 리포트 보기](https://mixpanel.com/project/3611536/view/4111120/app/insights#KiFtfc3QzAyS) + +--- + +## 🎯 분석 2: 지원하기 클릭 유저 vs 비클릭 유저 체류시간 비교 + +### 주요 지표 + +- **측정 이벤트**: `ClubDetailPage Duration` (상세페이지 체류시간) +- **세그먼트**: `Club Apply Button Clicked` 클릭 횟수 +- **측정 지표**: 중간값 및 평균값 + +### 중간값 기준 비교 + +| 클릭 횟수 | 중간값 (초) | 전체 대비 | +| ------------- | ----------- | --------- | +| **전체 평균** | **7** | - | +| **0-2번** | **8** | ⬆️ +14.3% | +| 2-4번 | 8 | ⬆️ +14.3% | +| 4-6번 | 8 | ⬆️ +14.3% | +| 6-8번 | 7 | - | +| 8-10번 | 7 | - | +| 10-12번 | 7 | - | +| 12-14번 | 6 | ⬇️ -14.3% | +| **14번 이상** | **6** | ⬇️ -14.3% | + +### 평균값 기준 비교 + +| 클릭 횟수 | 평균값 (초) | 전체 대비 | +| ------------- | ----------- | --------- | +| **전체 평균** | **20.7** | - | +| 0-2번 | 22.89 | ⬆️ +10.6% | +| **2-4번** | **32.83** | ⬆️ +58.6% | +| 4-6번 | 18.36 | ⬇️ -11.3% | +| 6-8번 | 19.2 | ⬇️ -7.2% | +| 8-10번 | 13.29 | ⬇️ -35.8% | +| 10-12번 | 20.5 | ⬇️ -1.0% | +| 12-14번 | 13.02 | ⬇️ -37.1% | +| 14번 이상 | 12.6 | ⬇️ -39.1% | + +### 핵심 인사이트 + +#### 🔥 매우 흥미로운 발견: "2-4번 클릭" 유저의 극단적으로 긴 체류시간 + +**2-4번 클릭 유저의 특이 패턴** + +- 평균 **32.83초** (전체 평균 20.7초 대비 **+58.6%**) +- 가장 신중하게 동아리를 탐색하는 유저 그룹 +- 여러 동아리를 비교 검토하며 의사결정에 많은 시간 투자 + +#### 클릭 빈도에 따른 3가지 유저 타입 분류 + +**타입 A: 신중한 탐색형 (0-4번 클릭)** + +- **특징**: 평균 22.89~32.83초의 긴 체류시간 +- **행동 패턴**: 여러 동아리를 신중하게 비교 검토 +- **의사결정**: 많은 정보를 수집한 후 결정 +- **비즈니스 의미**: 전환 가능성이 있는 고려 단계 유저 + +**타입 B: 적극적 탐색형 (4-12번 클릭)** + +- **특징**: 평균 18.36~20.5초의 중간 체류시간 +- **행동 패턴**: 관심 있는 여러 동아리를 적극적으로 탐색 +- **의사결정**: 비교 검토 후 선택 +- **비즈니스 의미**: 플랫폼 참여도가 높은 활성 유저 + +**타입 C: 빠른 결정형 / 파워유저 (14번 이상)** + +- **특징**: 평균 12.6초로 가장 짧은 체류시간 +- **행동 패턴**: 빠른 스캔 후 즉시 결정 +- **의사결정**: 명확한 선호도 기반 빠른 판단 +- **비즈니스 의미**: 플랫폼에 익숙하거나 특정 카테고리 선호 명확 + +### 가능한 해석 + +#### 1. U자형 체류시간 패턴의 의미 + +**낮은 클릭수 (0-4번)**: 긴 체류시간 + +- 동아리 정보를 천천히 읽으며 탐색 +- 아직 지원 결정을 내리지 못한 상태 +- 더 많은 확신이 필요 + +**중간 클릭수 (4-12번)**: 중간 체류시간 + +- 균형잡힌 탐색과 행동 +- 적절한 정보 수집 후 결정 + +**높은 클릭수 (14번+)**: 짧은 체류시간 + +- 빠른 정보 스캔 +- 효율적인 의사결정 +- 플랫폼 사용 숙련도 높음 + +#### 2. 2-4번 클릭 유저의 극단적 체류시간 이유 + +**가설 1: 비교 검토 단계** + +- 2-4개의 동아리를 후보로 선정 +- 각 동아리 상세페이지를 여러 번 재방문하며 비교 +- 최종 결정 전 신중한 검토 + +**가설 2: 정보 충분성 부족** + +- 지원 결정을 내리기에 정보가 부족하다고 느낌 +- 페이지를 오래 읽지만 확신을 얻지 못함 +- UX 개선 포인트일 수 있음 + +**가설 3: 높은 관심도** + +- 진지하게 동아리 가입을 고려 중 +- 모든 정보를 꼼꼼히 읽음 +- 높은 전환 가능성 + +### 비즈니스 시사점 + +#### ✅ 긍정적 신호 + +**효율적인 정보 전달** + +- 14번 이상 클릭한 파워유저들의 짧은 체류시간(12.6초) +- 필요한 정보를 빠르게 찾을 수 있는 페이지 구조 + +**다양한 유저 니즈 충족** + +- 신중한 유저부터 빠른 결정자까지 모두 수용 +- 유저 타입별로 차별화된 행동 패턴 확인 + +#### 🔍 추가 분석 및 개선 필요 + +**2-4번 클릭 유저 그룹 심층 분석** + +- 이 그룹의 최종 전환율 확인 필요 +- 32.83초의 긴 체류시간이 전환으로 이어지는지 분석 +- 전환되지 않는다면 정보 부족이나 UX 문제일 수 있음 + +**클릭 후 전환 퍼널 분석** + +- 각 유저 타입별 지원서 제출률 +- 클릭 → 지원서 진입 → 제출 완료 전환율 +- 이탈이 발생하는 주요 지점 파악 + +**재방문 패턴 분석** + +- 각 유저 타입의 재방문 빈도 +- 재방문 시 행동 변화 +- 첫 방문과 재방문의 체류시간 차이 + +### 리포트 링크 + +- [중간값 비교 리포트](https://mixpanel.com/project/3611536/view/4111120/app/insights#Em9txBZ4To24) +- [평균값 비교 리포트](https://mixpanel.com/project/3611536/view/4111120/app/insights#eFpNWYYrgVKB) + +--- + +## 📊 기간별 비교 (1-3월 vs 최근 2주) + +### TOP10 동아리 변화 + +**1-3월 장기 TOP3** + +1. 차사랑 (13초) +2. 집현전 (12초) +3. 스타피쉬-농구, 스타피쉬-축구, 부경다이버 (11초) + +**최근 2주 TOP3** + +1. 백경 유스호스텔 (22초) ⬆️ +2. 포시즌 (18초) ⬆️ +3. 집현전 (16초) ⬆️ + +**주요 변화** + +- **백경 유스호스텔**: 장기 9초 → 최근 22초 (144% 증가) 🔥 +- **집현전**: 일관되게 상위권 유지 (12초 → 16초) +- **포시즌**: 장기 7초 → 최근 18초 (157% 증가) 🔥 +- 최근 전반적으로 체류시간 증가 추세 + +### 전체 중간값 변화 + +- **1-3월**: 7초 +- **최근 2주**: 8초 +- **변화**: +1초 (+14.3%) + +### 유저 행동 패턴 일관성 + +**공통 패턴** + +- 낮은 클릭수 유저가 더 긴 체류시간 (양쪽 기간 모두 동일) +- 높은 클릭수 유저는 짧은 체류시간 + +**차이점** + +- 1-3월: 2-4번 클릭 유저의 극단적 긴 체류시간 (32.83초) 발견 +- 최근 2주: 이러한 극단적 패턴이 덜 명확함 + +--- + +## 📌 다음 분석 제안 + +### 1. 2-4번 클릭 유저 그룹 심층 분석 + +- 이 그룹의 최종 지원서 제출률 +- 탐색 패턴 (어떤 동아리들을 주로 비교하는가?) +- 이탈 지점 파악 + +### 2. 급상승 동아리 분석 + +- 백경 유스호스텔, 포시즌의 최근 체류시간 급증 원인 +- 페이지 콘텐츠나 동아리 정보 변경 여부 +- 마케팅 활동이나 특별 이벤트 여부 + +### 3. 장기 트렌드 분석 + +- 월별 체류시간 추이 +- 시즌별 패턴 (학기 시작/종료, 모집 시즌) +- 플랫폼 개선과 체류시간의 상관관계 + +### 4. 유저 타입별 전환 퍼널 + +- 타입 A(신중형) vs 타입 C(빠른 결정형)의 최종 전환율 비교 +- 각 타입의 LTV(생애 가치) 분석 +- 타겟 마케팅 전략 수립 + +### 5. 콘텐츠 최적화 + +- 긴 체류시간을 유도하는 콘텐츠 요소 파악 +- 빠른 전환을 돕는 핵심 정보 배치 전략 +- A/B 테스트 후보 아이디어 도출 diff --git a/docs/weekly-reports/2026-03-05-to-04-14-always-open-apply-click-rate.md b/docs/weekly-reports/2026-03-05-to-04-14-always-open-apply-click-rate.md new file mode 100644 index 000000000..774e34207 --- /dev/null +++ b/docs/weekly-reports/2026-03-05-to-04-14-always-open-apply-click-rate.md @@ -0,0 +1,85 @@ +# 상시모집 동아리 지원하기 클릭률 분석 + +**분석 기간**: 2026-03-05 ~ 2026-04-14 +**분석 일자**: 2026-04-14 +**데이터 소스**: Mixpanel (Project ID: 3611536) + +--- + +## 📊 분석: 상시모집 동아리 지원하기 클릭률 순위 + +### 측정 방법 + +- **분모**: `ClubDetailPage Visited` (filter: `recruitmentStatus = ALWAYS`) — 상세페이지 방문 수 +- **분자**: `Club Apply Button Clicked` — 지원하기 버튼 클릭 수 +- **클릭률**: (지원하기 클릭 수 / 상세페이지 방문 수) × 100 + +### 결과 테이블 + +| 순위 | 동아리 | 상세페이지 방문 | 지원하기 클릭 | 클릭률 | +| ---- | -------------------- | --------------- | ------------- | ------ | +| 1 | 바구니 | 49 | 20 | 40.8% | +| 2 | 스타피쉬-축구 | 47 | 19 | 40.4% | +| 3 | 후라 | 63 | 25 | 39.7% | +| 4 | 매니아 | 42 | 15 | 35.7% | +| 5 | 불교학생회 | 73 | 24 | 32.9% | +| 6 | 백경클래식기타연구회 | 37 | 9 | 24.3% | +| 7 | 모비딕 | 78 | 17 | 21.8% | +| 8 | PKNUO | 40 | 4 | 10.0% | +| 9 | CCC | 35 | 3 | 8.6% | +| 10 | SFC | 26 | 2 | 7.7% | +| 11 | 보블리스 | 26 | 1 | 3.8% | +| 12 | 테크니칼 | 35 | 1 | 2.9% | +| 13 | 모비딕스 | 39 | 1 | 2.6% | +| — | IVF | 16 | 데이터 미수집 | — | +| — | JDM | 17 | 데이터 미수집 | — | +| — | 전통예술연구회 터 | 30 | 데이터 미수집 | — | + +> **참고**: IVF, JDM, 전통예술연구회 터는 `@slug` 방식이 아닌 내부 ID 기반(`/webview/club/{id}`) URL을 사용해 동아리명과 클릭 데이터를 자동 매핑하지 못했습니다. + +### 절대 클릭 수 기준 순위 (참고) + +| 순위 | 동아리 | 클릭 수 | +| ---- | -------------------- | ------- | +| 1 | 후라 | 25 | +| 2 | 불교학생회 | 24 | +| 3 | 바구니 | 20 | +| 4 | 스타피쉬-축구 | 19 | +| 5 | 모비딕 | 17 | +| 6 | 매니아 | 15 | +| 7 | 백경클래식기타연구회 | 9 | +| 8 | PKNUO | 4 | +| 9 | CCC | 3 | +| 10 | SFC | 2 | + +--- + +## 🎯 인사이트 + +### 핵심 발견 + +- **클릭률 상위 3개** (바구니·스타피쉬-축구·후라)는 모두 35~41% 수준으로 근소한 차이 — 페이지를 방문한 3명 중 1명 이상이 지원하기를 클릭 +- **후라·불교학생회**는 방문 수는 많지만 클릭률이 상위권에 비해 낮음 — 유입은 많으나 지원 의향 전환에서 차이 발생 +- **모비딕**은 방문 수 1위(78회)임에도 클릭률 21.8%로 중위권 — 트래픽 대비 전환 최적화 여지 있음 +- **PKNUO, CCC, SFC** 이하는 클릭률 10% 미만으로 방문자 대부분이 지원으로 이어지지 않음 + +### 가능한 해석 + +1. **콘텐츠 충실도**: 클릭률 상위 동아리는 상세페이지 정보(소개·사진)가 충분해 방문자 의사결정이 빠름 +2. **타깃 매칭**: 특정 커뮤니티(예: 축구·악기 연주)에서 유입된 방문자일수록 전환율이 높을 수 있음 +3. **모집 긴박감 부재**: 상시모집이라 마감 압박이 없어, 하위권 동아리는 "나중에" 지원하려는 의향이 많을 수 있음 + +--- + +## 📌 다음 분석 제안 + +1. **하위 클릭률 동아리 상세페이지 UX 점검** — CCC, SFC, 보블리스 페이지에서 방문자가 이탈하는 지점 파악 (ClubDetailPage Duration 분석) +2. **IVF, JDM, 전통예술연구회 터 ID 매핑** — webview URL과 clubName 연결 후 전체 16개 동아리 완전 분석 +3. **기간 비교** — 2월과 비교해 상시모집 동아리 클릭률 변화 트렌드 확인 + +--- + +### 리포트 링크 + +- [상시모집 동아리 상세페이지 방문수](https://mixpanel.com/project/3611536/view/4111120/app/insights#fZoMsLaMTETp) +- [지원하기 버튼 클릭 - URL별](https://mixpanel.com/project/3611536/view/4111120/app/insights#13MyLd9p2JMf) diff --git a/docs/weekly-reports/2026-W14-club-engagement-analysis.md b/docs/weekly-reports/2026-W14-club-engagement-analysis.md new file mode 100644 index 000000000..a8ccd441f --- /dev/null +++ b/docs/weekly-reports/2026-W14-club-engagement-analysis.md @@ -0,0 +1,142 @@ +# 동아리 상세페이지 체류시간 및 지원 행동 분석 + +**분석 기간**: 2026-03-21 ~ 2026-04-04 (2주) +**분석 일자**: 2026-04-04 +**데이터 소스**: Mixpanel + +--- + +## 📊 분석 1: 상세페이지 체류시간 TOP10 동아리 + +### 주요 지표 + +- **측정 이벤트**: `ClubDetailPage Duration` +- **측정 지표**: 체류시간 중간값 (`duration_seconds`) +- **분석 방법**: 동아리별 체류시간 중간값 집계 + +### TOP10 동아리 + +| 순위 | 동아리명 | 중간값 (초) | +| ---- | -------------------- | ----------- | +| 1 | 백경 유스호스텔 | 22 | +| 2 | 포시즌 | 18 | +| 3 | 집현전 | 16 | +| 4 | 동반 | 15 | +| 5 | 바구니 | 14 | +| 6 | 쇳물결 | 13 | +| 6 | 백경클래식기타연구회 | 13 | +| 8 | SIC | 12 | +| 8 | 짚신 유스호스텔 | 12 | +| 8 | 조나단 | 12 | +| 8 | 네오쇼크 | 12 | + +### 인사이트 + +- **백경 유스호스텔**이 22초로 압도적으로 높은 체류시간을 기록 +- 상위 3개 동아리(백경 유스호스텔, 포시즌, 집현전)가 15초 이상의 높은 체류시간 보유 +- 전체 중간값은 8초로, TOP10 동아리들은 평균 대비 1.5~2.75배 높은 체류시간 + +### 리포트 링크 + +[Mixpanel 리포트 보기](https://mixpanel.com/project/3611536/view/4111120/app/insights#2S12C3WQcDSh) + +--- + +## 🎯 분석 2: 지원하기 클릭 유저 vs 비클릭 유저 체류시간 비교 + +### 주요 지표 + +- **측정 이벤트**: `ClubDetailPage Duration` (상세페이지 체류시간) +- **세그먼트**: `Club Apply Button Clicked` 클릭 횟수 +- **측정 지표**: 중간값 및 평균값 + +### 중간값 기준 비교 + +| 클릭 횟수 | 중간값 (초) | 전체 대비 | +| -------------------- | ----------- | --------- | +| **전체 평균** | **8** | - | +| **0번 (클릭 안 함)** | **9** | ⬆️ +12.5% | +| 1번 | 7 | ⬇️ -12.5% | +| 2번 | 9 | - | +| 3번 | 7 | ⬇️ -12.5% | +| 4번 | 6 | ⬇️ -25% | +| 5번 | 8.5 | ⬆️ +6.25% | + +### 평균값 기준 비교 + +| 클릭 횟수 | 평균값 (초) | 전체 대비 | +| -------------------- | ----------- | --------- | +| **전체 평균** | **16.39** | - | +| **0번 (클릭 안 함)** | **17.23** | ⬆️ +5.1% | +| 1번 | 14.18 | ⬇️ -13.5% | +| 2번 | 14.49 | ⬇️ -11.6% | +| 3번 | 15.04 | ⬇️ -8.2% | +| 4번 | 10.83 | ⬇️ -33.9% | +| 5번 | 14.27 | ⬇️ -12.9% | + +### 핵심 인사이트 + +#### 예상과 다른 결과 발견 ⚠️ + +**지원하기를 클릭하지 않은 유저가 더 긴 체류시간을 보임** + +- 중간값: 9초 (1번 클릭 유저 7초 대비 +28.6%) +- 평균값: 17.23초 (전체 평균 16.39초 대비 +5.1%) + +**1-5번 클릭한 유저들은 더 짧은 체류시간** + +- 평균 10.83~15.04초 (전체 평균보다 8.2%~33.9% 낮음) +- 중간값 6~9초 + +### 가능한 해석 + +#### 1. 빠른 의사결정 패턴 + +- 지원하기를 클릭한 유저들은 필요한 정보를 빠르게 파악하고 다음 단계(지원서 작성)로 이동 +- 체류시간이 짧다고 해서 관심도가 낮은 것이 아니라, 오히려 행동 전환이 빠른 것 + +#### 2. 탐색 vs 전환 행동 + +- **비클릭 유저 (탐색형)**: 여러 동아리를 비교하거나 정보를 천천히 읽으며 탐색 +- **클릭 유저 (전환형)**: 빠르게 정보를 스캔하고 지원 결정을 내림 + +#### 3. 정보 충분성의 차이 + +- 클릭하지 않은 유저는 더 많은 정보나 확신이 필요해 페이지에 더 오래 머물 수 있음 +- 클릭한 유저는 이미 충분한 정보를 얻었거나 강한 동기부여가 있음 + +### 비즈니스 시사점 + +#### ✅ 긍정적 신호 + +- 짧은 체류시간 = 명확한 정보 전달 성공 +- 페이지가 지원 의사결정을 효율적으로 돕고 있음 + +#### 🔍 추가 분석 필요 + +- 실제 지원서 제출률과의 상관관계 분석 필요 +- 클릭 후 이탈률 확인 (지원서 작성 페이지 진입 → 제출) +- 비클릭 유저의 이후 행동 패턴 분석 (재방문, 다른 동아리 탐색 등) + +### 리포트 링크 + +- [중간값 비교 리포트](https://mixpanel.com/project/3611536/view/4111120/app/insights#bctooJzo7owu) +- [평균값 비교 리포트](https://mixpanel.com/project/3611536/view/4111120/app/insights#9WtTsPxuQy95) + +--- + +## 📌 다음 분석 제안 + +1. **전환율 퍼널 분석** + - 상세페이지 방문 → 지원하기 클릭 → 지원서 제출 전환율 + +2. **재방문 패턴 분석** + - 비클릭 유저의 재방문 및 최종 전환율 + - 첫 방문 vs 재방문 시 행동 차이 + +3. **동아리별 전환 패턴** + - 체류시간이 긴 동아리의 실제 지원율 + - 동아리 카테고리별 체류시간 및 전환율 차이 + +4. **디바이스별 행동 차이** + - 모바일 vs 데스크톱 체류시간 및 전환율 diff --git "a/frontend/.claude/agents/\352\263\265\355\206\265\354\273\264\355\217\254\353\204\214\355\212\270\353\266\200\354\204\234.md" "b/frontend/.claude/agents/\352\263\265\355\206\265\354\273\264\355\217\254\353\204\214\355\212\270\353\266\200\354\204\234.md" new file mode 100644 index 000000000..e6b364a54 --- /dev/null +++ "b/frontend/.claude/agents/\352\263\265\355\206\265\354\273\264\355\217\254\353\204\214\355\212\270\353\266\200\354\204\234.md" @@ -0,0 +1,258 @@ +# 공통 컴포넌트 Agent + +`src/components/common/` 공통 UI 컴포넌트 생성, 사용, 리팩토링 전담 에이전트 + +## 역할 + +- 공통 컴포넌트 신규 생성 및 수정 +- 기존 컴포넌트 리팩토링 및 확장성 개선 +- 컴포넌트 간 일관된 패턴 유지 +- 접근성(a11y) 및 타입 안전성 보장 + +## 디렉토리 구조 + +컴포넌트는 `src/components/common/컴포넌트명/` 폴더 단위로 구성: + +```text +src/components/common/ +└── Button/ + ├── Button.tsx # 컴포넌트 구현 + ├── Button.styles.ts # styled-components 스타일 (있는 경우) + ├── Button.stories.tsx # Storybook 스토리 (있는 경우) + └── Button.test.tsx # 단위 테스트 (있는 경우) +``` + +### 왜 한 폴더에 모두 두는가 — 개발자 캐시 지역성 + +컴포넌트를 수정할 때는 구현·스타일·스토리·테스트가 **함께 바뀌는 경우가 대부분**이다. + +- **시간 지역성**: 방금 건드린 파일 근처를 곧 또 건드린다. 같은 폴더에 있으면 에디터 탐색 비용이 줄어든다. +- **공간 지역성**: 관련 파일이 물리적으로 가까울수록 맥락 전환 없이 작업할 수 있다. + +반대로 테스트를 `__tests__/`, 스토리를 `stories/` 같은 별도 폴더로 분산시키면 파일 하나 고칠 때마다 디렉토리 트리를 여러 곳 탐색해야 한다 — **개발자 캐시 미스**. 규모가 커질수록 이 비용이 커진다. + +따라서 함께 수정되는 파일은 반드시 같은 폴더에 위치시킨다. + +## 작업 프로세스 + +### 1. 새 공통 컴포넌트 생성 시 + +1. **필요성 판단** + - 2곳 이상에서 동일한 UI 패턴이 반복되는가? + - 독립적으로 재사용 가능한가? + - 기존 컴포넌트를 확장하는 방식이 더 적절하지 않은가? + +2. **폴더 및 파일 생성** + - `src/components/common/컴포넌트명/컴포넌트명.tsx` + - 스타일이 복잡하면 `컴포넌트명.styles.ts` 분리 + +3. **인터페이스 설계** + - 네이티브 HTML 요소 래핑 시: `extends ButtonHTMLAttributes` 등 확장 + - 복잡한 도메인 특화 props는 명시적 인터페이스로 정의 + +4. **내보내기** + - `export default 컴포넌트명` + +### 2. 기존 컴포넌트 수정 시 + +1. **현재 사용처 파악** + - 변경 전 어디서 사용되는지 확인 + - Breaking change 여부 판단 + +2. **하위 호환성 유지** + - 기존 props는 유지하거나 명시적으로 deprecated 처리 + - 선택적 props(`?`)로 확장, 필수 props 추가 금지 + +## 패턴 + +### 기본 컴포넌트 (네이티브 요소 래핑) + +네이티브 HTML 요소를 래핑할 때 **두 가지 방식** 중 하나를 선택한다. + +#### A. 명시적 커스텀 props (현재 Button.tsx 방식) + +노출할 props를 직접 열거한다. 외부에 전달하는 속성을 엄격하게 통제하고 싶을 때 적합하다. + +```typescript +export interface ButtonProps { + width?: string; + children: React.ReactNode; + type?: string; + onClick?: () => void; + animated?: boolean; + disabled?: boolean; + className?: string; +} + +const Button = ({ width, children, onClick, type, animated = false, disabled = false, className }: ButtonProps) => ( + + {children} + +); +``` + +#### B. HTMLAttributes extend (전체 HTML 속성 위임이 필요한 경우) + +`aria-*`, `data-*`, 이벤트 핸들러 등 네이티브 속성 전체를 그대로 지원해야 할 때 사용한다. + +```typescript +import type { ButtonHTMLAttributes } from 'react'; + +export interface ButtonProps extends ButtonHTMLAttributes { + width?: string; + animated?: boolean; +} + +const Button = ({ width, animated = false, type = 'button', children, ...rest }: ButtonProps) => ( + + {children} + +); +``` + +**적용 대상**: `button` → `ButtonHTMLAttributes`, `input` → `InputHTMLAttributes`, `textarea` → `TextareaHTMLAttributes` + +### 복합 컴포넌트 (Compound Component) + +독립적으로 사용하기 어려운 서브 컴포넌트가 있을 때 Context + 정적 메서드로 구성: + +```typescript +// Context 정의 — 이름은 컴포넌트명 + Context +const CustomDropDownContext = createContext | undefined>(undefined); + +const useDropDownContext = () => { + const context = useContext(CustomDropDownContext); + if (!context) throw new Error('useDropDownContext는 CustomDropDownContextProvider 내부에서 사용할 수 있습니다.'); + return context; +}; + +// 서브 컴포넌트 +const Trigger = ({ children }: { children: ReactNode }) => { ... }; +const Menu = ({ children, top, width, right }: MenuProps) => { ... }; // 포지셔닝 props 포함 +const Item = ({ value, children, style }: ItemProps) => { ... }; + +// 루트 컴포넌트는 named export + 제네릭 함수 형태 +export function CustomDropDown({ ... }: CustomDropDownProps) { + return ( + + {children} + + ); +} + +// 서브 컴포넌트를 정적 메서드로 연결 +CustomDropDown.Trigger = Trigger; +CustomDropDown.Menu = Menu; +CustomDropDown.Item = Item; +``` + +**적용 대상**: Dropdown, Tabs, Accordion 등 부모-자식 관계가 있는 복합 UI + +### 포털 컴포넌트 (Portal) + +모달, 툴팁처럼 DOM 계층을 벗어나야 할 때 `createPortal` 사용: + +```typescript +const PortalModal = ({ isOpen, onClose, children }: PortalModalProps) => { + useEffect(() => { + if (isOpen) document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; }; + }, [isOpen]); + + if (!isOpen) return null; + + const modalRoot = document.getElementById('modal-root'); + if (!modalRoot) return null; + + return createPortal({children}, modalRoot); +}; +``` + +**주의**: `index.html`에 `` 존재 확인 필요 + +### 스타일 파일 분리 (`*.styles.ts`) + +스타일이 많거나 복잡할 경우 별도 파일로 분리: + +```typescript +// 컴포넌트명.styles.ts +import styled from 'styled-components'; +// 컴포넌트명.tsx +import * as Styled from './컴포넌트명.styles'; + +export const Container = styled.div`...`; +export const Label = styled.label`...`; + +// , 형태로 사용 +``` + +간단한 컴포넌트는 `.tsx` 파일 내 인라인으로 작성해도 무방. + +## 주요 규칙 + +### 네이밍 + +- 컴포넌트 파일: `PascalCase.tsx` +- 스타일 파일: `PascalCase.styles.ts` +- 인터페이스: `컴포넌트명Props` (예: `ButtonProps`, `InputFieldProps`) +- styled-components 내부 전달 prop: `$` 접두사 (예: `$animated`, `$width`) + +### 타입 안전성 + +- `any` 금지 +- 제네릭 활용 (예: `CustomDropDown`) +- 옵셔널 props에는 적절한 기본값 제공 + +### 접근성 (a11y) + +- 네이티브 요소 래핑 시 `extends HTMLXxxAttributes`로 `aria-*` 자동 지원 +- 역할이 명확한 경우 `role` 속성 명시 (예: `role="listbox"`, `role="option"`) +- 이미지에 반드시 `alt` 제공 +- 키보드 인터랙션 고려 (포커스, Enter/Space 키) + +### styled-components + +- 테마는 `theme.colors`, `theme.typography`, `theme.transitions` 활용 +- 반응형은 `src/styles/mediaQuery.ts`의 브레이크포인트 사용 +- 조건부 스타일은 `css` 헬퍼 함수로 타입 안전하게 작성 + +```typescript +import styled, { css } from 'styled-components'; + +const StyledButton = styled.button<{ $animated: boolean }>` + ${({ $animated }) => + $animated && + css` + animation: ${pulse} 0.4s ease-in-out; + `} +`; +``` + +## 체크리스트 + +새 공통 컴포넌트 생성 또는 수정 시 확인: + +- [ ] 2곳 이상에서 재사용되는 컴포넌트인가? +- [ ] 네이티브 요소 래핑 시 `HTMLXxxAttributes` extend 했는가? +- [ ] `any` 타입 없이 명시적으로 타입이 정의되었는가? +- [ ] styled-components props에 `$` 접두사를 붙였는가? +- [ ] 기존 사용처에서 Breaking change가 없는가? +- [ ] 접근성 속성(`aria-*`, `role`, `alt`)이 적절히 처리되는가? +- [ ] 스타일은 테마 시스템(`theme.colors` 등)을 활용하는가? +- [ ] 테스트 파일(`컴포넌트명.test.tsx`)이 같은 폴더에 함께 위치하는가? + +## 참고 파일 + +- `src/components/common/Button/Button.tsx` - 기본 컴포넌트 패턴 (명시적 커스텀 props 인터페이스) +- `src/components/common/InputField/InputField.tsx` - 복잡한 props 인터페이스 예시 +- `src/components/common/Modal/PortalModal.tsx` - 포털 컴포넌트 패턴 +- `src/components/common/CustomDropDown/CustomDropDown.tsx` - 복합 컴포넌트(Compound) 패턴 +- `src/styles/mediaQuery.ts` - 반응형 브레이크포인트 +- `src/styles/theme/` - 테마 시스템 (colors, typography, transitions) + +## 기술 스택 + +- React 19 + TypeScript +- styled-components (스타일링) +- Framer Motion (애니메이션이 필요한 경우) +- React Portal (모달, 오버레이) diff --git a/frontend/.claude/commands/mixpanel.md b/frontend/.claude/commands/mixpanel.md new file mode 100644 index 000000000..18811e69f --- /dev/null +++ b/frontend/.claude/commands/mixpanel.md @@ -0,0 +1,613 @@ +--- +description: Mixpanel 데이터 분석 및 리포트 생성 +allowed-tools: Bash(mcp-cli *), Read, Write, Edit, Glob, Grep +--- + +# Mixpanel 데이터 분석 + +Mixpanel MCP를 사용하여 사용자 행동 데이터를 분석하고 인사이트를 도출합니다. + +**⚠️ 중요**: 모든 분석 결과는 자동으로 repo 루트 기준 `docs/weekly-reports/` 디렉토리에 마크다운 파일로 저장됩니다. + +--- + +## 필수 사전 작업 ⚠️ + +**CRITICAL**: MCP 툴 사용 전 반드시 스키마를 확인해야 합니다. + +```bash +# 1. 항상 먼저 스키마 확인 (MANDATORY) +mcp-cli info claude_ai_mixpanel/ + +# 2. 그 다음에 호출 +mcp-cli call claude_ai_mixpanel/ '{...}' +``` + +--- + +## 프로젝트 정보 + +### 기본 설정 + +- **Project ID**: `3611536` (Moadong) +- **Workspace ID**: `4111120` (All Project Data) +- **Test Project ID**: `3974708` (moa_test) + +### 주요 이벤트 + +**공통 사용자 플로우** (모든 동아리): + +- `MainPage Visited` - 메인 페이지 방문 +- `ClubCard Clicked` - 동아리 카드 클릭 +- `ClubDetailPage Visited` - 상세 페이지 방문 +- `ClubDetailPage Duration` - 상세 페이지 체류시간 +- `Club Apply Button Clicked` - 지원하기 버튼 클릭 + +**케이스 A: 내부 지원서 사용 동아리** (일부): + +- `Club Apply Button Clicked` 이후: + - → `ApplicationFormPage Visited` - 지원서 페이지 방문 + - → `ApplicationFormPage Duration` - 지원서 페이지 체류시간 + - → `Application Form Submitted` - 지원서 제출 + +**케이스 B: 외부 폼 사용 동아리** (대부분): + +- `Club Apply Button Clicked` 이후: + - → 외부 리다이렉트 (구글폼/네이버폼) + - → **추적 불가** - 외부 폼 제출 데이터는 Mixpanel에 기록되지 않음 + +**⚠️ 분석 시 주의사항**: + +- 전환율 분석 시 **내부 지원서 동아리만 필터링** 필요 +- `Application Form Submitted` 기반 분석은 전체 동아리를 대표하지 않음 +- `Club Apply Button Clicked`가 실제 지원 의도를 나타내는 더 정확한 지표 + +**관리자 플로우**: + +- `로그인페이지 Visited` - 관리자 로그인 페이지 +- `로그인 버튼클릭` - 로그인 시도 +- `사이드바 탭 클릭` - 관리자 사이드바 탭 이동 +- `동아리 기본 정보 수정 버튼클릭` - 기본 정보 수정 +- `동아리 모집 정보 수정 버튼클릭` - 모집 정보 수정 + +### 주요 프로퍼티 + +**이벤트 프로퍼티**: + +- `clubName` - 동아리명 (string) +- `club_id` - 동아리 ID (number/string) +- `duration_seconds` - 체류시간 (number, seconds) +- `url` - 페이지 URL (string) +- `$browser`, `$os`, `$device` - 디바이스 정보 +- ⚠️ **동아리 지원서 타입 구분 프로퍼티** - 확인 필요 (내부/외부 폼 구분) + +**사용자 프로퍼티**: + +- `$user_id` - 사용자 ID +- `$distinct_id` - 고유 식별자 + +**분석 팁**: + +- 외부 폼 사용 동아리를 제외하려면 `ApplicationFormPage Visited` 이벤트가 있는 동아리만 필터링 +- 또는 특정 동아리 리스트로 필터링 (내부 지원서 사용 동아리 화이트리스트) + +--- + +## Step 1: 분석 유형 확인 + +사용자가 요청한 분석 유형을 파악합니다: + +### 1. 기간별 트렌드 분석 + +- 주간/월간/분기별 지표 변화 +- 예: "지난 2주간 상세페이지 체류시간 분석" + +### 2. 코호트 분석 + +- 특정 행동을 한 유저 그룹 분석 +- 예: "지원하기 클릭한 유저 vs 안 한 유저 비교" + +### 3. TOP N 분석 + +- 상위/하위 항목 순위 +- 예: "체류시간이 가장 긴 동아리 TOP10" + +### 4. 퍼널 분석 + +- 전환율 및 이탈률 분석 +- 예: "메인 → 상세 → 지원 전환 퍼널" + +### 5. 주간 리포트 + +- 정기 KPI 모니터링 +- 예: "이번 주 주간 리포트 생성" + +--- + +## Step 2: 필요한 데이터 확인 + +### 2-1. 이벤트 검색 + +```bash +# 키워드로 이벤트 검색 +mcp-cli info claude_ai_mixpanel/Get-Events +mcp-cli call claude_ai_mixpanel/Get-Events '{"project_id": 3611536, "query": "detail"}' +``` + +### 2-2. 이벤트 프로퍼티 확인 + +```bash +# 특정 이벤트의 프로퍼티 확인 +mcp-cli info claude_ai_mixpanel/Get-Property-Names +mcp-cli call claude_ai_mixpanel/Get-Property-Names '{ + "project_id": 3611536, + "resource_type": "Event", + "event": "ClubDetailPage Duration" +}' +``` + +--- + +## Step 3: 쿼리 스키마 확인 (MANDATORY) + +분석 타입에 맞는 스키마를 확인합니다: + +```bash +# Insights 쿼리 스키마 (가장 자주 사용) +mcp-cli info claude_ai_mixpanel/Get-Query-Schema +mcp-cli call claude_ai_mixpanel/Get-Query-Schema '{"report_type": "insights"}' + +# Funnel 쿼리 스키마 +mcp-cli call claude_ai_mixpanel/Get-Query-Schema '{"report_type": "funnels"}' + +# Retention 쿼리 스키마 +mcp-cli call claude_ai_mixpanel/Get-Query-Schema '{"report_type": "retention"}' +``` + +**주의**: 스키마가 크면 파일로 저장되므로 Read 툴로 읽어야 합니다. + +--- + +## Step 4: 분석 실행 + +### 패턴 1: 체류시간 TOP N 분석 + +```bash +mcp-cli info claude_ai_mixpanel/Run-Query +mcp-cli call claude_ai_mixpanel/Run-Query - <<'EOF' +{ + "project_id": 3611536, + "report_type": "insights", + "report": { + "name": "상세페이지 체류시간 TOP10", + "dateRange": { + "type": "relative", + "range": { + "unit": "day", + "value": 14 + } + }, + "metrics": [ + { + "eventName": "ClubDetailPage Duration", + "measurement": { + "type": "aggregate-property", + "math": "median", + "propertyName": "duration_seconds" + } + } + ], + "breakdowns": [ + { + "metric": { + "type": "property", + "propertyName": "clubName", + "propertyType": "string", + "resource": "event" + } + } + ], + "chartType": "table" + } +} +EOF +``` + +### 패턴 2: 유저 코호트 비교 + +```bash +mcp-cli call claude_ai_mixpanel/Run-Query - <<'EOF' +{ + "project_id": 3611536, + "report_type": "insights", + "report": { + "name": "지원 클릭 유저 vs 비클릭 유저 체류시간", + "dateRange": { + "type": "relative", + "range": { + "unit": "day", + "value": 14 + } + }, + "metrics": [ + { + "eventName": "ClubDetailPage Duration", + "measurement": { + "type": "aggregate-property", + "math": "median", + "propertyName": "duration_seconds" + } + } + ], + "breakdowns": [ + { + "metric": { + "type": "frequency-per-user", + "eventName": "Club Apply Button Clicked" + } + } + ], + "chartType": "bar" + } +} +EOF +``` + +### 패턴 3: 기간별 트렌드 (케이스 A: 내부 지원서만) + +```bash +# ⚠️ 내부 지원서 사용 동아리만 추적 가능 +mcp-cli call claude_ai_mixpanel/Run-Query - <<'EOF' +{ + "project_id": 3611536, + "report_type": "insights", + "report": { + "name": "일별 지원서 제출 추이 (내부 폼)", + "dateRange": { + "type": "relative", + "range": { + "unit": "day", + "value": 30 + } + }, + "metrics": [ + { + "eventName": "Application Form Submitted", + "measurement": { + "type": "basic", + "math": "total" + } + } + ], + "chartType": "line", + "unit": "day" + } +} +EOF +``` + +### 패턴 4: 전체 지원 의도 추이 (케이스 A+B 모두) + +```bash +# ✅ 내부/외부 폼 모두 포함한 실제 지원 의도 +mcp-cli call claude_ai_mixpanel/Run-Query - <<'EOF' +{ + "project_id": 3611536, + "report_type": "insights", + "report": { + "name": "일별 지원하기 버튼 클릭 추이 (전체)", + "dateRange": { + "type": "relative", + "range": { + "unit": "day", + "value": 30 + } + }, + "metrics": [ + { + "eventName": "Club Apply Button Clicked", + "measurement": { + "type": "basic", + "math": "total" + } + } + ], + "chartType": "line", + "unit": "day" + } +} +EOF +``` + +### 패턴 5: 내부 vs 외부 폼 동아리 비교 + +```bash +# 동아리별 ApplicationFormPage Visited 발생 여부로 내부/외부 구분 +mcp-cli call claude_ai_mixpanel/Run-Query - <<'EOF' +{ + "project_id": 3611536, + "report_type": "insights", + "report": { + "name": "동아리별 지원 방식 구분", + "dateRange": { + "type": "relative", + "range": { + "unit": "day", + "value": 30 + } + }, + "metrics": [ + { + "eventName": "Club Apply Button Clicked", + "measurement": { + "type": "basic", + "math": "total" + } + }, + { + "eventName": "ApplicationFormPage Visited", + "measurement": { + "type": "basic", + "math": "total" + } + } + ], + "breakdowns": [ + { + "metric": { + "type": "property", + "propertyName": "clubName", + "propertyType": "string", + "resource": "event" + } + } + ], + "chartType": "table" + } +} +EOF +# ApplicationFormPage Visited가 0인 동아리 = 외부 폼 사용 +``` + +--- + +## Step 5: 결과 분석 및 해석 + +### 5-1. 데이터 해석 가이드 + +**중간값 vs 평균값**: + +- **중간값(median)**: 이상치에 덜 민감, 일반적인 사용자 행동 파악 +- **평균값(average)**: 전체 트렌드, 이상치 포함 + +**체류시간 해석**: + +- 짧은 체류시간 ≠ 낮은 관심도 +- 빠른 의사결정 = 명확한 정보 전달 성공 가능 +- 긴 체류시간 = 탐색형 유저 또는 정보 부족 + +**클릭 횟수 패턴**: + +- 0번: 탐색 중이거나 관심 없음 +- 1-5번: 일반적인 탐색 및 지원 +- 14번+: 파워유저 또는 비교 검토 중 + +**⚠️ 전환율 해석 시 주의사항 (중요!)**: + +**`Application Form Submitted` 기반 전환율** (케이스 A만): + +- ❌ **전체 동아리를 대표하지 않음** - 내부 지원서 동아리만 포함 +- 사용 목적: 내부 폼 UX 개선 효과 측정 +- 전체 서비스 성과로 해석 금지 + +**`Club Apply Button Clicked` 기반 전환율** (케이스 A+B) ✅: + +- ✅ **추천 지표** - 실제 지원 의도를 나타냄 +- 내부/외부 폼 구분 없이 전체 성과 파악 가능 +- 동아리 상세페이지 효과 측정에 적합 + +**분석 목적에 따른 선택**: + +- 전체 플랫폼 성과 파악 → `Club Apply Button Clicked` 사용 +- 내부 지원서 UX 분석 → `Application Form Submitted` 사용 +- 두 지표를 함께 보면서 내부/외부 폼 사용 비율 파악 + +### 5-2. 인사이트 도출 + +분석 결과를 바탕으로 다음을 도출합니다: + +1. **예상과 다른 결과 강조** - 놀라운 발견 ⚠️ +2. **유저 타입 분류** - 행동 패턴별 세그먼트 +3. **가능한 해석 제시** - 3가지 이상의 가설 +4. **비즈니스 시사점** - 긍정적 신호 + 개선 포인트 +5. **다음 분석 제안** - 추가 탐색 방향 + +**⚠️ 인사이트 도출 후 필수 작업**: + +인사이트를 도출한 후에는 **반드시 Step 6**으로 이동하여 분석 결과를 자동으로 파일에 저장해야 합니다. + +--- + +## Step 6: 문서화 + +### 6-1. 리포트 파일 자동 생성 + +**CRITICAL**: 분석 완료 후 반드시 결과를 자동으로 파일에 저장해야 합니다. + +분석 결과를 repo 루트 기준 `docs/weekly-reports/` 디렉토리에 **자동으로** 저장합니다. + +**파일명 규칙**: + +- 주간 리포트: `YYYY-WNN-description.md` (예: `2026-W14-club-engagement-analysis.md`) +- 기간별 리포트: `YYYY-MM-to-MM-description.md` (예: `2026-01-to-03-club-engagement-analysis.md`) +- 특정 분석: `YYYY-MM-DD-description.md` (예: `2026-04-03-club-detail-visit-analysis.md`) + +**자동 저장 프로세스**: + +1. 분석 쿼리 실행 및 결과 수집 +2. 인사이트 도출 및 해석 +3. 아래 템플릿에 맞춰 마크다운 작성 +4. **Write 툴을 사용하여 repo 루트 기준 `docs/weekly-reports/[파일명].md` 경로에 저장** (절대 경로는 `git rev-parse --show-toplevel` 결과를 앞에 붙여 계산) +5. 저장 완료 후 사용자에게 파일 경로 안내 + +**예시**: + +```bash +# 분석 완료 후 자동 저장 +# Write 툴 사용하여 파일 생성 +file_path: /docs/weekly-reports/2026-04-03-club-detail-visit-analysis.md # repo 루트는 git rev-parse --show-toplevel 으로 확인 +content: [템플릿 기반으로 작성한 마크다운 내용] +``` + +### 6-2. 리포트 템플릿 + +```markdown +# [분석 제목] + +**분석 기간**: YYYY-MM-DD ~ YYYY-MM-DD +**분석 일자**: YYYY-MM-DD +**데이터 소스**: Mixpanel + +--- + +## 📊 분석 1: [분석명] + +### 주요 지표 + +- **측정 이벤트**: `Event Name` +- **측정 지표**: 중간값/평균값 +- **분석 방법**: 설명 + +### 결과 테이블 + +| 순위/구분 | 항목 | 값 | +| --------- | ---- | --- | +| ... | ... | ... | + +### 인사이트 + +- 핵심 발견 1 +- 핵심 발견 2 +- 핵심 발견 3 + +### 리포트 링크 + +[Mixpanel 리포트 보기](URL) + +--- + +## 🎯 분석 2: [분석명] + +(동일 구조 반복) + +--- + +## 📊 기간별 비교 (선택사항) + +이전 기간과의 비교 분석 + +--- + +## 📌 다음 분석 제안 + +1. **제안 1** - 설명 +2. **제안 2** - 설명 +3. **제안 3** - 설명 +``` + +--- + +## 자주 사용하는 분석 시나리오 + +### 시나리오 1: 주간 리포트 생성 + +```text +사용자: /mixpanel 지난주 주간 리포트 생성해줘 +``` + +→ `docs/mixpanel-weekly-report-prompts.md`의 프롬프트 1-8 실행 + +### 시나리오 2: TOP N 동아리 분석 + +```text +사용자: /mixpanel 2주간 체류시간 TOP10 동아리 +``` + +→ 체류시간 중간값 기준 상위 10개 동아리 분석 (모든 동아리 대상) + +### 시나리오 3: 유저 코호트 비교 + +```text +사용자: /mixpanel 지원하기 클릭한 유저 vs 안 한 유저 체류시간 비교 +``` + +→ 클릭 횟수별 체류시간 분석 (중간값 + 평균값) + +### 시나리오 4: 급상승 동아리 원인 분석 + +```text +사용자: /mixpanel 백경 유스호스텔 체류시간 급증 원인 분석 +``` + +→ 시계열 분석 + 프로퍼티 비교 + +### 시나리오 5: 내부 지원서 전환율 분석 (케이스 A만) + +```text +사용자: /mixpanel 내부 지원서 사용 동아리의 지원 완료율 +``` + +→ `ApplicationFormPage Visited` → `Application Form Submitted` 전환율 +→ ⚠️ 외부 폼 동아리는 제외됨 + +### 시나리오 6: 전체 지원 의도 분석 (케이스 A+B) + +```text +사용자: /mixpanel 지원하기 버튼 클릭률 분석 +``` + +→ `ClubDetailPage Visited` → `Club Apply Button Clicked` 전환율 +→ ✅ 내부/외부 폼 모두 포함하는 실제 지원 의도 지표 + +--- + +## 주의사항 + +### ⚠️ 필수 규칙 + +1. **스키마 확인 우선**: `mcp-cli info` 없이 `mcp-cli call` 금지 +2. **프로젝트 ID 확인**: 3611536(운영) vs 3974708(테스트) +3. **이벤트명 정확히**: 대소문자, 띄어쓰기 정확히 일치 +4. **날짜 범위 합리적**: 너무 긴 기간은 타임아웃 가능 +5. **리포트 링크 포함**: 모든 쿼리 결과에 Mixpanel URL 포함 +6. **⚠️ 지원서 타입 구분 필수**: 내부 폼 vs 외부 폼 동아리 구분 주의 + - `Application Form Submitted`는 일부 동아리만 해당 + - 전체 성과는 `Club Apply Button Clicked` 사용 + +### 🚫 하지 말아야 할 것 + +- 스키마 확인 없이 파라미터 추측 +- 존재하지 않는 이벤트/프로퍼티 사용 +- 너무 많은 breakdown (성능 저하) +- 분석 결과 없이 추측으로 인사이트 작성 +- **`Application Form Submitted`를 전체 동아리 성과로 해석** ❌ + - 내부 지원서 동아리만 포함됨을 명시해야 함 + +--- + +## 참고 문서 + +### 프로젝트 내 문서 + +- 주간 리포트 프롬프트: `docs/mixpanel-weekly-report-prompts.md` +- 관리자 리포트 프롬프트: `docs/mixpanel-admin-weekly-report-prompts.md` +- 리포팅 가이드: `docs/mixpanel-reporting.md` + +### 이벤트 정의 + +- 프론트엔드 이벤트: `src/constants/eventName.ts` +- Mixpanel 초기화: `src/utils/initSDK.ts` + +### 기존 리포트 + +- `docs/weekly-reports/` - 과거 분석 리포트 참고 diff --git a/frontend/.gitignore b/frontend/.gitignore index f8d648482..0fb107ba4 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -23,3 +23,9 @@ public/sitemap.xml .env.sentry-build-plugin AGENTS.md + +# oh-my-claudecode +.omc/ + +# Claude Code (personal settings) +.claude/ diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 9fb87dbd1..c4fbd322c 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -30,6 +30,11 @@ npm run storybook # 포트 6006에서 Storybook 시작 npm run build-storybook # Storybook 빌드 npm run chromatic # Chromatic으로 시각적 테스트 배포 +# Storybook 사용 가이드 (공통 컴포넌트 수정 시) +# - 개발 중: npm run storybook (dev 서버로 실시간 확인) +# - 기존 스토리가 있는 컴포넌트 수정 후 PR 전: npm run build-storybook +# - 스토리가 없는 신규 컴포넌트: npm run typecheck 로 충분 + # 유틸리티 npm run generate:sitemap # sitemap.xml 생성 ``` @@ -56,6 +61,7 @@ npm run generate:sitemap # sitemap.xml 생성 - **Sentry**: 에러 모니터링 및 성능 추적 - **Channel.io**: 고객 지원 채팅 - **Kakao SDK**: 카카오 공유 기능 +- **Naver Map**: 동아리방 위치 지도 (네이버 클라우드 플랫폼) 모든 SDK는 `src/utils/initSDK.ts`에서 초기화되며, 각각 환경 변수 필요. @@ -70,6 +76,7 @@ npm run generate:sitemap # sitemap.xml 생성 - `VITE_ENABLE_SENTRY_IN_DEV` - 개발 환경에서 Sentry 활성화 여부 (true/false) - `VITE_CHANNEL_PLUGIN_KEY` - Channel.io 플러그인 키 - `VITE_KAKAO_JAVASCRIPT_KEY` - Kakao JavaScript 키 +- `VITE_NAVER_MAP_CLIENT_ID` - 네이버 지도 API 클라이언트 ID ### 프로젝트 구조 @@ -205,15 +212,17 @@ const { variant } = useExperiment(mainBannerExperiment); `.claude/agents/` 디렉토리에 전담 agent 정의: -- `api-hooks-agent.md` - React Query 훅 생성 및 관리 전담 +- `API훅부서.md` - React Query 훅 생성 및 관리 전담 Agent 사용 시 해당 문서를 참조하여 일관된 패턴 유지. -## 코딩 컨벤션 +--- + +## 도메인별 상세 문서 -- **네이밍**: camelCase (변수, 함수), PascalCase (컴포넌트, 타입) -- **파일명**: 컴포넌트는 PascalCase.tsx, 유틸은 camelCase.ts -- **Import 순서**: 외부 라이브러리 → 내부 모듈 → 타입 → 스타일 -- **스타일**: styled-components 사용, 테마 시스템 활용 -- **타입**: any 금지, 명시적 타입 정의 -- **상수**: UPPER_SNAKE_CASE, `src/constants/`에서 관리 +@docs/claude/architecture.md +@docs/claude/api.md +@docs/claude/ui.md +@docs/claude/testing.md +@docs/claude/features.md +@docs/claude/conventions.md diff --git a/frontend/docs/claude/api.md b/frontend/docs/claude/api.md new file mode 100644 index 000000000..745dc37f5 --- /dev/null +++ b/frontend/docs/claude/api.md @@ -0,0 +1,39 @@ +# API & 인증 + +## API 레이어 패턴 + +API는 `src/apis/utils/apiHelpers.ts`의 헬퍼 함수를 사용하는 일관된 패턴을 따름: + +- `handleResponse()` - 응답 파싱, `{ data: {...} }` 형식 자동 언래핑 +- `secureFetch()` - 인증된 요청, 401 시 토큰 자동 갱신 + +쿼리 키는 `src/constants/queryKeys.ts`에 중앙 관리. + +## 인증 플로우 + +- JWT는 localStorage에 저장 (`accessToken` 키, `src/constants/storageKeys.ts`에서 관리) +- 리프레시 토큰은 쿠키로 처리 +- `src/apis/auth/secureFetch.ts`의 `secureFetch()`가 자동 토큰 갱신 담당 +- 어드민 라우트는 `PrivateRoute` 컴포넌트로 보호 + +## 외부 서비스 통합 + +- **Mixpanel**: 사용자 분석 및 이벤트 트래킹 +- **Sentry**: 에러 모니터링 및 성능 추적 +- **Channel.io**: 고객 지원 채팅 +- **Kakao SDK**: 카카오 공유 기능 + +모든 SDK는 `src/utils/initSDK.ts`에서 초기화되며, 각각 환경 변수 필요. + +## 상수 관리 + +`src/constants/`에 모든 상수 중앙 관리: + +- `queryKeys.ts` - React Query 쿼리 키 (도메인.액션 형식) +- `storageKeys.ts` - localStorage 키 (`accessToken`, `hasConsentedPersonalInfo`) +- `status.ts` - 지원 상태 정의 (PENDING, APPROVED, REJECTED 등) +- `eventName.ts` - Mixpanel 이벤트명 +- `api.ts` - API 엔드포인트 URL +- `snsConfig.ts` - SNS 플랫폼 설정 +- `applicationForm.ts` - 지원서 폼 설정 +- `uploadLimit.ts` - 파일 업로드 제한 diff --git a/frontend/docs/claude/architecture.md b/frontend/docs/claude/architecture.md new file mode 100644 index 000000000..d2f6a0a75 --- /dev/null +++ b/frontend/docs/claude/architecture.md @@ -0,0 +1,46 @@ +# 아키텍처 + +## 기술 스택 + +- React 19 + TypeScript +- Vite 번들러 (webpack 설정도 있으나 Vite가 주력) +- styled-components 스타일링 +- TanStack React Query v5 서버 상태 관리 +- Zustand 클라이언트 상태 관리 +- React Router v7 +- date-fns 날짜 처리 +- Framer Motion 애니메이션 +- Swiper 캐러셀 +- react-datepicker 날짜 선택 +- react-markdown 마크다운 렌더링 + +## 환경 변수 + +`.env` 파일에 다음 환경 변수 설정 필요 (모두 `VITE_` 접두사 사용): + +- `VITE_API_URL` - 백엔드 API URL +- `VITE_MIXPANEL_TOKEN` - Mixpanel 프로젝트 토큰 +- `VITE_SENTRY_DSN` - Sentry DSN +- `VITE_SENTRY_RELEASE` - Sentry 릴리즈 버전 +- `VITE_ENABLE_SENTRY_IN_DEV` - 개발 환경에서 Sentry 활성화 여부 (true/false) +- `VITE_CHANNEL_PLUGIN_KEY` - Channel.io 플러그인 키 +- `VITE_KAKAO_JAVASCRIPT_KEY` - Kakao JavaScript 키 + +## 프로젝트 구조 + +**경로 별칭**: `@/*`는 `src/*`로 매핑 + +**주요 디렉토리**: + +- `src/apis/` - 도메인별 API 함수 (club, auth, application, applicants) +- `src/hooks/Queries/` - API를 래핑하는 React Query 훅 (useClub, useApplication, useApplicants) +- `src/store/` - Zustand 스토어 (useCategoryStore, useSearchStore) +- `src/pages/` - 라우트 기반 페이지 컴포넌트 +- `src/components/` - 공용 UI 컴포넌트 +- `src/context/` - React Context 프로바이더 (AdminClubContext - SSE 상태 관리) +- `src/experiments/` - A/B 테스트 실험 정의 및 관리 +- `src/mocks/` - MSW(Mock Service Worker) 핸들러 +- `src/utils/` - 유틸리티 함수 (날짜 파싱, 유효성 검사, 디바운스, WebView 브릿지 등) +- `src/errors/` - 커스텀 에러 클래스 +- `src/types/` - 공용 타입 정의 +- `src/constants/` - 상수 관리 (queryKeys, storageKeys, status, eventName, api, snsConfig 등) diff --git a/frontend/docs/claude/conventions.md b/frontend/docs/claude/conventions.md new file mode 100644 index 000000000..f3cfb0acc --- /dev/null +++ b/frontend/docs/claude/conventions.md @@ -0,0 +1,18 @@ +# 코딩 컨벤션 + +## 네이밍 + +- 변수, 함수: camelCase +- 컴포넌트, 타입: PascalCase +- 파일명: 컴포넌트는 PascalCase.tsx, 유틸은 camelCase.ts +- 상수: UPPER_SNAKE_CASE + +## Import 순서 + +외부 라이브러리 → 내부 모듈 → 타입 → 스타일 + +## 스타일 + +- styled-components 사용, 테마 시스템 활용 +- `any` 금지, 명시적 타입 정의 +- 상수는 `src/constants/`에서 관리 diff --git a/frontend/docs/claude/features.md b/frontend/docs/claude/features.md new file mode 100644 index 000000000..0a92498a2 --- /dev/null +++ b/frontend/docs/claude/features.md @@ -0,0 +1,31 @@ +# 주요 기능 + +## 실험(A/B 테스트) 프레임워크 + +`src/experiments/`에서 Mixpanel 기반 실험 관리: + +- `definitions.ts` - 실험 정의 (key, variants, weights) +- `ExperimentRepository.ts` - 실험 할당 및 변형 조회 로직 +- `initializeExperiments.ts` - 앱 시작 시 실험 초기화 +- `useExperiment()` 훅으로 컴포넌트에서 실험 변형 사용 + +```typescript +const { variant } = useExperiment(mainBannerExperiment); +// variant는 'A' 또는 'B' +``` + +## 실시간 업데이트 + +지원자 상태 업데이트를 위해 SSE(Server-Sent Events) 사용, `AdminClubContext`에서 관리. + +## 주요 유틸리티 함수 + +`src/utils/`에 공용 유틸리티 함수 모음: + +- `formatRelativeDateTime.ts` - 상대적 시간 표시 ("2시간 전") +- `recruitmentDateParser.ts` - 모집 기간 파싱 +- `debounce.ts` - 디바운스 함수 +- `validateSocialLink.ts` - SNS 링크 유효성 검사 +- `isInAppWebView.ts` - 인앱 WebView 감지 +- `webviewBridge.ts` - 네이티브 앱과 통신 +- `initSDK.ts` - 외부 SDK 초기화 (Mixpanel, Sentry, Channel.io, Kakao) diff --git a/frontend/docs/claude/testing.md b/frontend/docs/claude/testing.md new file mode 100644 index 000000000..a758dc2de --- /dev/null +++ b/frontend/docs/claude/testing.md @@ -0,0 +1,23 @@ +# 테스트 & Storybook + +## 테스트 + +- Jest + React Testing Library +- MSW로 API 모킹 +- 테스트 파일은 `*.test.ts` 또는 `*.test.tsx` 형식 +- 커버리지 리포트: `npm run coverage` +- 단일 파일 실행: `npx jest path/to/file.test.ts` + +## MSW (Mock Service Worker) + +`src/mocks/`에서 API 모킹 관리: + +- `handlers/` - 도메인별 모킹 핸들러 +- `browser.ts` - MSW 브라우저 워커 설정 +- Storybook 및 개발 환경에서 사용 + +## Storybook + +- 컴포넌트 독립 개발 환경 (포트 6006) +- MSW addon으로 API 모킹 지원 +- Chromatic으로 시각적 회귀 테스트 diff --git a/frontend/docs/claude/ui.md b/frontend/docs/claude/ui.md new file mode 100644 index 000000000..adca95ebd --- /dev/null +++ b/frontend/docs/claude/ui.md @@ -0,0 +1,30 @@ +# UI & 스타일 + +## 반응형 브레이크포인트 + +`src/styles/mediaQuery.ts`에 정의: + +- mini_mobile: 375px +- mobile: 500px +- tablet: 700px +- laptop: 1280px +- Desktop: 1280px 초과 (기본값) + +## 테마 시스템 + +테마는 `src/styles/theme/`에 colors, typography, transitions로 정의. styled-components `ThemeProvider`를 통해 접근. + +## 애니메이션 + +- `framer-motion` 라이브러리로 페이지 전환, 모달, 제스처 등 애니메이션 구현 +- `src/styles/theme/transitions.ts`에 공통 트랜지션 정의 + +## 캐러셀 + +- `swiper` 라이브러리로 이미지 슬라이더, 카드 캐러셀 구현 + +## 날짜 처리 + +- `date-fns` 라이브러리 사용 (Moment.js 대신) +- `formatRelativeDateTime` 유틸로 상대 시간 표시 +- `react-datepicker` 컴포넌트로 날짜 입력 diff --git a/frontend/docs/features/components/Button.md b/frontend/docs/features/components/Button.md new file mode 100644 index 000000000..ed395c980 --- /dev/null +++ b/frontend/docs/features/components/Button.md @@ -0,0 +1,42 @@ +# Button 컴포넌트 + +공용 버튼 컴포넌트. `React.ButtonHTMLAttributes`를 extend하여 HTML 버튼의 모든 속성을 지원한다. + +## Props + +| Prop | Type | Default | 설명 | +| ---------- | ---------------------------- | -------- | ------------------------------------------------------------------------ | +| `width` | `string` | `'auto'` | 버튼 너비 (예: `'100%'`, `'150px'`) | +| `animated` | `boolean` | `false` | hover 시 pulse 애니메이션, active 시 scale 축소 | +| 그 외 | `React.ButtonHTMLAttributes` | — | `type`, `disabled`, `onClick`, `aria-*`, `data-*` 등 모든 HTML 버튼 속성 | + +## 사용 예시 + +```tsx +// 기본 + + +// 너비 지정 + submit + + +// 애니메이션 + 비활성화 + +``` + +## 스타일 + +테마 시스템(`theme.colors`, `theme.typography`)을 참조한다. + +- 배경: `gray[900]` (#3A3A3A) +- 텍스트: `base.white` +- 높이: 42px, border-radius: 10px +- 폰트: `typography.paragraph.p2` (16px, weight 600) +- disabled: `gray[500]` 배경, `gray[600]` 텍스트 + +## 관련 코드 + +- `src/components/common/Button/Button.tsx` — 컴포넌트 구현 +- `src/styles/theme/colors.ts` — 색상 토큰 +- `src/styles/theme/typography.ts` — 타이포그래피 토큰 diff --git a/frontend/docs/features/components/Portal.md b/frontend/docs/features/components/Portal.md new file mode 100644 index 000000000..1b9bec038 --- /dev/null +++ b/frontend/docs/features/components/Portal.md @@ -0,0 +1,30 @@ +# Portal 컴포넌트 분리 및 Modal 리팩토링 + +`createPortal`을 추상화한 범용 `Portal` 컴포넌트를 `common/Portal/`로 분리하고, `Modal`이 이를 사용하도록 리팩토링했다. + +## 배경 + +기존 `Modal`(구 `PortalModal`)은 `createPortal` 호출, 스크롤 잠금, 오버레이 처리를 모두 담당했다. Portal 렌더링 로직을 별도 컴포넌트로 분리해 툴팁, 토스트, 드로어 등 다른 컴포넌트에서도 재사용할 수 있도록 했다. + +## Portal 컴포넌트 + +```tsx +interface PortalProps { + children: ReactNode; + rootId?: string; // 기본값: 'modal-root' +} +``` + +- `rootId` prop으로 다른 DOM root에도 렌더링 가능 +- `children`은 `ReactNode` 직접 선언 (필수값이므로 `PropsWithChildren` 미사용) + +## Modal 변경 사항 + +- `createPortal` 직접 호출 → `` 컴포넌트로 교체 +- `useEffect` cleanup 버그 수정: `if (!isOpen) return` 가드 추가로 불필요한 overflow 리셋 방지 +- `onClick` 핸들러 단순화: `closeOnBackdrop ? onClose : undefined` + +## 관련 코드 + +- `src/components/common/Portal/Portal.tsx` — 범용 포탈 컴포넌트 +- `src/components/common/Modal/Modal.tsx` — Portal을 사용하는 모달 래퍼 diff --git a/frontend/docs/features/components/header.md b/frontend/docs/features/components/header.md new file mode 100644 index 000000000..b085a1a61 --- /dev/null +++ b/frontend/docs/features/components/header.md @@ -0,0 +1,36 @@ +# Header 컴포넌트 구조 + +`Header` 컴포넌트는 UI 렌더링만 담당하고, 비즈니스 로직은 전용 훅으로 분리되어 있다. + +## 역할 분리 + +| 관심사 | 담당 | +|--------|------| +| 네비게이션 + 트래킹 | `useHeaderNavigation` | +| 렌더 조건 판단 (showOn/hideOn) | `useHeaderVisibility` | +| 스크롤 감지 | `useScrollDetection` | +| UI 렌더링 | `Header.tsx` | + +## useHeaderVisibility + +`showOn`, `hideOn` props를 받아 현재 디바이스에서 Header를 렌더링할지 결정한다. + +```ts +const isVisible = useHeaderVisibility(showOn, hideOn); +if (!isVisible) return null; +``` + +- `hideOn`이 `showOn`보다 우선순위가 높다 +- 내부에서 `useDevice`, `isInAppWebView`를 호출해 현재 디바이스 타입을 판단 +- `DeviceType`은 `src/types/device.ts`에서 공통 관리 + +## useHeaderNavigation + +네비게이션 이동과 Mixpanel 트래킹을 함께 처리한다. `handleMenuClose`를 통해 모바일 메뉴 닫기 트래킹도 담당. + +## 관련 코드 + +- `src/components/common/Header/Header.tsx` — UI 렌더링 +- `src/hooks/Header/useHeaderVisibility.ts` — 렌더 조건 훅 +- `src/hooks/Header/useHeaderNavigation.ts` — 네비게이션 + 트래킹 훅 +- `src/types/device.ts` — DeviceType 공통 타입 diff --git a/frontend/docs/features/experiments/mixpanel-super-property.md b/frontend/docs/features/experiments/mixpanel-super-property.md new file mode 100644 index 000000000..633d87385 --- /dev/null +++ b/frontend/docs/features/experiments/mixpanel-super-property.md @@ -0,0 +1,34 @@ +# A/B 실험 variant를 Mixpanel super property로 등록 + +앱 시작 시 실험 배정 결과를 Mixpanel super property로 등록하여, 이후 발생하는 모든 이벤트에 variant 정보가 자동으로 포함되도록 한다. + +## 동작 방식 + +`ExperimentRepository.fetchAndAssignExperiments()`가 호출될 때: + +- **신규 유저**: 새로 variant가 배정되면 즉시 `mixpanel.register()` 호출 +- **재방문 유저**: localStorage에 유효한 배정값이 있으면 해당 값으로 `mixpanel.register()` 호출 + +super property key는 실험의 `key` 필드 그대로 사용한다 (예: `main_banner_v1`). + +## Mixpanel 대시보드 활용 + +데이터가 쌓이면 기존 Insights/Funnel 쿼리에 breakdown만 추가하면 된다. + +```text +예시: ClubDetailPage Visited 이벤트 +→ Breakdown: main_banner_v1 +→ A그룹 vs B그룹 비교 +``` + +## 주의사항 + +- `localhost`에서는 `mixpanel.disable()`이 적용되어 super property 등록이 동작하지 않음 +- 스테이징/프로덕션 배포 후부터 데이터 수집 시작 + +## 관련 코드 + +- `src/experiments/ExperimentRepository.ts` — super property 등록 로직 +- `src/experiments/definitions.ts` — 실험 정의 (key, variants, weights) +- `src/utils/initSDK.ts` — Mixpanel 초기화 (`initializeMixpanel`) +- `src/index.tsx` — 초기화 순서: `initializeMixpanel()` → `initializeExperiments()` diff --git a/frontend/docs/features/game/game-page-layout.md b/frontend/docs/features/game/game-page-layout.md new file mode 100644 index 000000000..d1a717f30 --- /dev/null +++ b/frontend/docs/features/game/game-page-layout.md @@ -0,0 +1,32 @@ +# GamePage 레이아웃 및 DotTextEffect 인터랙션 개선 + +## 레이아웃 구조 + +`TopRow`를 3-column grid(`1fr auto 1fr`)로 구성하여 타이틀을 절대 중앙에 고정하고 순위표를 오른쪽 끝에 배치. +DotTextEffect는 전체 너비 가운데, 클릭 버튼은 하단(`marginTop: 40px`)에 위치. + +``` +┌─────────────────────────────────────────────┐ +│ [빈 공간] 동아리 클릭 배틀 [실시간 순위] │ +│ │ +│ [ 개 발 팀 (DotText) ] │ +│ │ +│ [클릭! 버튼] │ +└─────────────────────────────────────────────┘ +``` + +## DotTextEffect 색상 Ripple + +마우스 커서 주변 `colorRadius(= hoverRadius * 1.8)` 범위 내 dot들이 거리 비례로 색상이 물드는 효과. + +- 파워 커브 `Math.pow(dist / colorRadius, 2.5)` 적용 → 중심만 진하고 바깥은 급격히 회색으로 +- 각 dot에 `charColors` 중 랜덤 색상 미리 배정 (글자 단위 → dot 단위 랜덤) +- `hoverRadius: 18`, `dotR: 1.8` (겹침 방지) + +## 관련 코드 + +- `src/pages/GamePage/GamePage.tsx` — 레이아웃 구조 (TopRow, DotTextEffect 중앙, 버튼 하단) +- `src/pages/GamePage/GamePage.styles.ts` — TopRow grid 스타일 +- `src/pages/GamePage/components/DotTextEffect/DotTextEffect.tsx` — 색상 ripple 및 랜덤 색상 로직 +- `src/pages/GamePage/components/RankingBoard/RankingBoard.styles.ts` — Header column 방향 변경 +- `src/pages/GamePage/components/ClickButton/ClickButton.styles.ts` — ClubLabel 말줄임 처리 diff --git a/frontend/docs/features/hooks/useClubSuggestions.md b/frontend/docs/features/hooks/useClubSuggestions.md new file mode 100644 index 000000000..f12f17edf --- /dev/null +++ b/frontend/docs/features/hooks/useClubSuggestions.md @@ -0,0 +1,27 @@ +# useClubSuggestions — 자동완성 race condition 방지 + +자동완성 입력에서 debounce 후 클럽 목록을 조회하는 훅. React Query의 쿼리 키 단위 상태 관리를 활용해 이전 요청 응답이 늦게 돌아와도 현재 입력 기준 결과만 렌더링에 반영된다. + +## 배경 + +기존 `ClubNameInput`은 `debounceRef` + `getClubList` 직접 호출 방식으로, 응답 순서 검증 없이 `setSuggestions`를 수행했다. 네트워크 지연 시 이전 요청 결과가 최신 입력을 덮어쓰는 race condition이 발생할 수 있었다. + +## 해결 방식 + +컴포넌트에서 `debouncedKeyword` state를 별도로 관리하고, `useClubSuggestions(debouncedKeyword)`에 전달한다. React Query는 쿼리 키가 바뀌는 순간 이전 쿼리 결과를 무시하므로 race condition이 원천 차단된다. + +```typescript +// useEffect로 300ms debounce +useEffect(() => { + const timer = setTimeout(() => setDebouncedKeyword(value.trim()), 300); + return () => clearTimeout(timer); +}, [value]); + +const { data: suggestions = [] } = useClubSuggestions(debouncedKeyword); +``` + +## 관련 코드 + +- `src/hooks/Queries/useClub.ts` — `useClubSuggestions` 훅 정의 (enabled: !!keyword.trim(), staleTime: 30s) +- `src/constants/queryKeys.ts` — `queryKeys.club.suggestions(keyword)` 키 추가 +- `src/pages/GamePage/components/ClubNameInput/ClubNameInput.tsx` — debounceRef 제거, useClubSuggestions 적용 diff --git a/frontend/docs/features/hooks/useValidateClubName.md b/frontend/docs/features/hooks/useValidateClubName.md new file mode 100644 index 000000000..7451ebe6a --- /dev/null +++ b/frontend/docs/features/hooks/useValidateClubName.md @@ -0,0 +1,30 @@ +# useValidateClubName — 제출 검증 React Query 캐시 통합 + +`ClubNameInput`의 submit 경로에서 동아리 이름 존재 여부를 검증하는 훅. `useClubSuggestions`와 동일한 캐시 키를 공유해 캐시 히트 시 네트워크 요청 없이 검증한다. + +## 배경 + +기존 `handleSubmit`은 `getClubList(trimmed)`를 직접 호출했다. 자동완성 경로(`useClubSuggestions`)가 `queryKeys.club.suggestions(keyword)` 캐시를 사용하는 반면, 제출 검증 경로는 동일 키워드에 대해 별도 요청을 발생시키고 있었다. + +## 해결 방식 + +`queryClient.ensureQueryData`로 동일한 캐시 키를 사용한다. 캐시가 유효하면 재요청 없이 반환하고, 없으면 fetch 후 캐시에 저장한다. + +```typescript +export const useValidateClubName = () => { + const queryClient = useQueryClient(); + return async (name: string) => { + const { clubs } = await queryClient.ensureQueryData({ + queryKey: queryKeys.club.suggestions(name), + queryFn: () => getClubList(name), + staleTime: 30 * 1000, + }); + return clubs.some((c) => c.name === name); + }; +}; +``` + +## 관련 코드 + +- `src/hooks/Queries/useClub.ts` — `useValidateClubName` 훅 정의 +- `src/pages/GamePage/components/ClubNameInput/ClubNameInput.tsx` — `handleSubmit`에서 `useValidateClubName` 사용 diff --git a/frontend/docs/superpowers/specs/2026-04-14-design-system-toolkit-design.md b/frontend/docs/superpowers/specs/2026-04-14-design-system-toolkit-design.md new file mode 100644 index 000000000..8c91d0d4b --- /dev/null +++ b/frontend/docs/superpowers/specs/2026-04-14-design-system-toolkit-design.md @@ -0,0 +1,361 @@ +# Design System Toolkit — 설계 문서 + +**날짜**: 2026-04-14 +**작성자**: seongwon seo +**상태**: 승인됨 + +--- + +## 배경 및 목표 + +### 문제 + +- 코드베이스 전반에 하드코딩된 색상값(`#3B82F6`, `rgb(...)` 등)이 산재해 있어 UI 일관성 유지가 어렵다. +- 디자인 토큰이 TypeScript 파일로만 관리되어 단일 진실 공급원(single source of truth)이 불명확하다. +- 토큰 위반을 감지하는 자동화 수단이 없어 코드 리뷰에서 사람이 직접 확인해야 한다. + +### 목표 + +1. 디자인 토큰을 JSON 파일로 중앙 관리하고, 빌드 시 TypeScript 파일로 자동 변환한다. +2. ESLint 커스텀 룰로 하드코딩된 값을 개발 중 실시간 감지한다. +3. GitHub Actions로 PR마다 토큰 위반을 자동으로 리포트한다. + +### 범위 외 (이번 버전) + +- Figma API 직접 연동 (향후 확장) +- AI 기반 컴포넌트 코드 자동 생성 (향후 확장) +- 간격(spacing), 그림자(shadow) 등 색상 외 토큰의 ESLint 룰 (향후 확장) + +--- + +## 아키텍처 + +``` +[tokens/] ← 단일 진실 공급원 (JSON) + ├── colors.json + ├── typography.json + └── spacing.json + ↓ +[Style Dictionary] ← 빌드 타임 변환 (npm run ds:build) + ↓ +[src/styles/theme/] ← 자동 생성 (직접 편집 금지) + ├── colors.ts ✦ generated + ├── typography.ts ✦ generated + └── index.ts + ↓ +[ESLint custom rule] ← 개발 중 실시간 위반 감지 + ↓ +[GitHub Actions] ← PR마다 audit 결과 코멘트 +``` + +**핵심 원칙**: + +- `tokens/`가 단일 진실 공급원. 토큰 수정 시 반드시 JSON 파일만 수정. +- 기존 styled-components + ThemeProvider 사용 방식은 변경 없음. +- 3단계를 독립적으로 배포 가능 — 1단계만 완료해도 팀에 가치가 있음. + +--- + +## 1단계: 토큰 파이프라인 (Style Dictionary) + +### 파일 구조 + +``` +tokens/ +├── colors.json +├── typography.json +└── spacing.json + +config/ +└── style-dictionary.config.js + +src/styles/theme/ ← Style Dictionary 출력 (기존 경로 유지) +├── colors.ts ← ⚠️ AUTO-GENERATED. Edit tokens/ instead. +├── typography.ts ← ⚠️ AUTO-GENERATED. Edit tokens/ instead. +├── transitions.ts ← 수동 관리 (애니메이션은 토큰화 범위 외) +└── index.ts +``` + +### 토큰 JSON 형식 + +```json +// tokens/colors.json +{ + "color": { + "primary": { "value": "#3B82F6", "type": "color" }, + "primary-hover": { "value": "#2563EB", "type": "color" }, + "text-default": { "value": "#111827", "type": "color" }, + "text-muted": { "value": "#6B7280", "type": "color" }, + "background": { "value": "#FFFFFF", "type": "color" }, + "border": { "value": "#E5E7EB", "type": "color" } + } +} +``` + +### Style Dictionary 설정 + +```js +// config/style-dictionary.config.js +module.exports = { + source: ['tokens/**/*.json'], + platforms: { + ts: { + transformGroup: 'js', + buildPath: 'src/styles/theme/', + files: [ + { + destination: 'colors.ts', + format: 'javascript/es6', + filter: { type: 'color' }, + }, + { + destination: 'typography.ts', + format: 'javascript/es6', + filter: { type: 'typography' }, + }, + ], + }, + }, +}; +``` + +### npm 스크립트 + +```json +{ + "ds:build": "style-dictionary build --config config/style-dictionary.config.js", + "ds:watch": "style-dictionary build --watch --config config/style-dictionary.config.js", + "prebuild": "npm run ds:build" +} +``` + +`prebuild` 훅으로 `npm run build` 전에 자동 실행. (기존 `prebuild` 훅 없음 — 충돌 없이 추가 가능) + +### 마이그레이션 전략 + +1. 기존 `src/styles/theme/colors.ts` 값을 `tokens/colors.json`으로 이관 +2. Style Dictionary로 동일한 `colors.ts` 재생성 (출력 검증) +3. 기존 파일 상단에 `// ⚠️ AUTO-GENERATED. Edit tokens/ instead.` 주석 추가 +4. `.gitignore`에 추가하지 않음 — 생성 파일도 git에 포함해 CI 없이도 팀이 바로 사용 가능 + +--- + +## 2단계: ESLint 커스텀 룰 + +### 감지 대상 + +styled-components 템플릿 리터럴 내부의 하드코딩된 색상값: + +- HEX: `#rgb`, `#rrggbb`, `#rrggbbaa` +- RGB/RGBA: `rgb(...)`, `rgba(...)` +- HSL/HSLA: `hsl(...)`, `hsla(...)` +- Named colors: `red`, `blue` 등 CSS 색상 키워드 + +```tsx +// ❌ 위반 — ESLint 경고 +const Button = styled.button` + color: #3b82f6; + background: rgb(0, 0, 0); +`; + +// ✅ 통과 +const Button = styled.button` + color: ${({ theme }) => theme.colors.primary}; +`; +``` + +### 구현 + +``` +src/eslint-rules/ +└── no-hardcoded-design-tokens.js +``` + +- AST에서 `TaggedTemplateExpression` (styled-components) 탐지 +- 템플릿 리터럴 문자열에서 색상 패턴 정규식 매칭 +- 위반 시 토큰 역매핑으로 자동 제안 (`#3B82F6` → `theme.colors.primary`) +- `tokens/colors.json`을 읽어 제안 목록 동적 생성 + +### ESLint 설정 + +```js +// eslint.config.mjs ← 프로젝트 기존 파일에 추가 +import noHardcodedTokens from './src/eslint-rules/no-hardcoded-design-tokens.js'; + +export default [ + { + plugins: { + 'design-system': { rules: { 'no-hardcoded-tokens': noHardcodedTokens } }, + }, + rules: { + 'design-system/no-hardcoded-tokens': 'warn', // 안정화 후 'error'로 승격 + }, + }, +]; +``` + +### 에러 메시지 형식 + +``` +Hardcoded color '#3B82F6' found. Use theme token instead: theme.colors.primary +``` + +--- + +## 3단계: GitHub Actions CI + +### 워크플로우 + +```yaml +# .github/workflows/design-audit.yml +name: Design System Audit + +on: + pull_request: + paths: + - 'src/**' + - 'tokens/**' + +jobs: + design-audit: + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - run: npm ci + - run: npm run ds:build + + - name: Run ESLint (design tokens audit) + id: lint + run: npm run lint -- --format json --output-file lint-results.json || true + + - name: Post PR comment + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const results = JSON.parse(fs.readFileSync('lint-results.json', 'utf8')); + const violations = results + .flatMap(r => r.messages + .filter(m => m.ruleId === 'design-system/no-hardcoded-tokens') + .map(m => ` ${r.filePath.replace(process.cwd(), '')}:${m.line} ${m.message}`) + ); + + const body = violations.length === 0 + ? '✅ **Design System Audit**: No hardcoded token violations found.' + : `🎨 **Design System Audit**: ${violations.length} violation(s) found.\n\n\`\`\`\n${violations.join('\n')}\n\`\`\``; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body + }); +``` + +### PR 코멘트 예시 + +``` +🎨 Design System Audit: 3 violation(s) found. + + /src/pages/MainPage/components/ClubCard/ClubCard.styles.ts:12 Hardcoded color '#3B82F6'. Use theme.colors.primary + /src/components/common/Modal/Modal.styles.ts:34 Hardcoded color '#111827'. Use theme.colors.text-default + /src/pages/AdminPage/components/SideBar/SideBar.styles.ts:8 Hardcoded color '#E5E7EB'. Use theme.colors.border +``` + +--- + +## 구현 순서 + +``` +Phase 1: 토큰 파이프라인 ~3일 + ├── tokens/ 디렉토리 + JSON 파일 작성 + ├── Style Dictionary 설치 및 설정 + ├── 기존 theme 파일 마이그레이션 + └── npm 스크립트 연결 + prebuild 훅 + +Phase 2: ESLint 커스텀 룰 ~3일 + ├── AST 기반 룰 작성 + ├── 토큰 역매핑 제안 로직 + ├── ESLint config 연결 + └── 기존 위반 목록 확인 (warn으로 시작) + +Phase 3: GitHub Actions ~1일 + ├── 워크플로우 파일 작성 + ├── PR 코멘트 스크립트 작성 + └── 테스트 PR로 동작 검증 +``` + +--- + +## Figma 연동 전략 + +토큰 파이프라인은 입력이 JSON이면 출처에 무관하게 동작하도록 설계되어 있다. Figma 연동은 파이프라인의 전제조건이 아니며, 단계적으로 도입한다. + +### 현재 상태 진단 기준 + +| Figma 상태 | 대응 방법 | +| ----------------------- | -------------------------------------- | +| Variables/Styles 미정의 | 코드에서 역추출 → `tokens/*.json` 작성 | +| Styles만 있음 | Tokens Studio 플러그인으로 내보내기 | +| Variables까지 있음 | Figma Variables API 자동 동기화 | + +### 단계별 전략 + +**Phase 0 (지금)**: 코드 역추출 + +현재 `src/styles/theme/colors.ts`, `typography.ts`에 이미 정의된 값을 `tokens/*.json`으로 이관. Figma 연동 없이 파이프라인 먼저 구축. + +``` +코드(theme/*.ts) → tokens/*.json → Style Dictionary → theme/*.ts (자동 생성) +``` + +**Phase 1 (파이프라인 안정화 후)**: Tokens Studio 반자동 연동 + +디자이너가 Figma에 Styles/Variables를 정의하면, [Tokens Studio](https://tokens.studio/) 플러그인으로 `tokens.json` 내보내기. 수동이지만 디자이너 주도로 동기화 가능. + +``` +Figma (Tokens Studio) → tokens/*.json export → PR → ds:build +``` + +**Phase 2 (선택)**: Figma Variables API 자동화 + +Figma Professional 플랜 이상에서 Variables API 사용 가능. `scripts/sync-figma-tokens.js` 스크립트로 완전 자동화. + +```bash +# Personal Access Token 필요 +FIGMA_TOKEN=xxx FILE_ID=yyy npm run ds:figma-sync +``` + +``` +Figma Variables API → scripts/sync-figma-tokens.js → tokens/*.json → ds:build +``` + +### 핵심 원칙 + +- **파이프라인 입력 포맷(JSON)은 고정** — Figma 연동 방식이 바뀌어도 이후 과정은 변경 없음 +- **디자이너와의 싱크 시점**: 파이프라인이 작동하기 시작하면 디자이너에게 Figma Styles/Variables 정의 요청. 코드 기준 토큰을 Figma에 역으로 반영하는 것도 가능. + +--- + +## 향후 확장 가능성 + +- **AI 코드 생성**: Claude API로 토큰 스펙 기반 컴포넌트 초안 생성 +- **spacing/shadow 룰**: ESLint 룰을 색상 외 토큰으로 확장 +- **토큰 사용 리포트**: 각 토큰이 몇 곳에서 쓰이는지 통계 생성 + +--- + +## 성공 기준 + +- [ ] `npm run ds:build` 실행 시 `tokens/*.json` → `src/styles/theme/*.ts` 변환 성공 +- [ ] 하드코딩된 색상값에 ESLint 경고가 표시됨 +- [ ] PR 오픈 시 GitHub Actions가 자동으로 위반 목록을 코멘트로 남김 +- [ ] 기존 `ThemeProvider` 기반 코드가 마이그레이션 후에도 정상 동작 diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 1448b3880..bb83417cb 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -11,6 +11,7 @@ const config = [ 'node_modules/**', 'coverage/**', 'public/**', + 'storybook-static/**', 'jest.config.js', 'jest.setup.ts', 'netlify.toml', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f21bd9e4..99c9378fd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,30 +1,13 @@ -import { lazy } from 'react'; -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { BrowserRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from 'styled-components'; import { ScrollToTopButton } from '@/components/common/ScrollToTopButton/ScrollToTopButton'; -import { AdminClubProvider } from '@/context/AdminClubContext'; import { ScrollToTop } from '@/hooks/Scroll/ScrollToTop'; -import LoginTab from '@/pages/AdminPage/auth/LoginTab/LoginTab'; -import PrivateRoute from '@/pages/AdminPage/auth/PrivateRoute/PrivateRoute'; -import ClubDetailPage from '@/pages/ClubDetailPage/ClubDetailPage'; -import MainPage from '@/pages/MainPage/MainPage'; +import AppRoutes from '@/routes/AppRoutes'; import GlobalStyles from '@/styles/Global.styles'; import { theme } from '@/styles/theme'; -import ApplicationFormPage from './pages/ApplicationFormPage/ApplicationFormPage'; -import GoogleCallbackPage from './pages/CallbackPage/GoogleCallbackPage'; -import ClubUnionPage from './pages/ClubUnionPage/ClubUnionPage'; -import IntroducePage from './pages/IntroducePage/IntroducePage'; +import { GlobalBoundary } from './components/common/ErrorBoundary'; import 'swiper/css'; -import { - ContentErrorBoundary, - GlobalBoundary, -} from './components/common/ErrorBoundary'; -import LegacyClubDetailPage from './pages/ClubDetailPage/LegacyClubDetailPage'; -import ErrorTestPage from './pages/ErrorTestPage/ErrorTestPage'; -import IntroductionPage from './pages/FestivalPage/IntroductionPage/IntroductionPage'; -import PromotionDetailPage from './pages/PromotionPage/PromotionDetailPage'; -import PromotionListPage from './pages/PromotionPage/PromotionListPage'; const queryClient = new QueryClient({ defaultOptions: { @@ -38,8 +21,6 @@ const queryClient = new QueryClient({ }, }); -const AdminRoutes = lazy(() => import('@/pages/AdminPage/AdminRoutes')); - const App = () => { return ( <> @@ -50,130 +31,7 @@ const App = () => { - - - - - } - /> - {/*기존 웹 & 안드로이드 url (android: v1.1.0)*/} - - - - } - /> - {/*웹 유저에게 신규 상세페이지 보유주기 위한 임시 url*/} - - - - } - /> - {/*한국어핸들 */} - - - - } - /> - {/*새로 빌드해서 배포할 앱 주소 url*/} - - - - } - /> - - - - } - /> - - - - } - /> - } - /> - } /> - - - - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - {/* 개발 환경에서만 사용 가능한 에러 테스트 페이지 */} - {import.meta.env.DEV && ( - } /> - )} - } /> - + diff --git a/frontend/src/apis/game.ts b/frontend/src/apis/game.ts new file mode 100644 index 000000000..430ce3b04 --- /dev/null +++ b/frontend/src/apis/game.ts @@ -0,0 +1,25 @@ +import API_BASE_URL from '@/constants/api'; +import { GameRankingResponse } from '@/types/game'; +import { handleResponse } from './utils/apiHelpers'; + +export const postGameClick = async ( + clubName: string, + count: number, +): Promise => { + const response = await fetch(`${API_BASE_URL}/api/game/click`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clubName, count, ctAt: new Date().toISOString() }), + }); + if (!response.ok) throw new Error('클릭 요청에 실패했습니다.'); +}; + +export const getGameRanking = async (): Promise => { + const response = await fetch(`${API_BASE_URL}/api/game/ranking`); + const data = await handleResponse( + response, + '랭킹을 불러오는데 실패했습니다.', + ); + if (!data) throw new Error('랭킹을 불러오는데 실패했습니다.'); + return data; +}; diff --git a/frontend/src/assets/images/icons/close_button_icon.svg b/frontend/src/assets/images/icons/close_button_icon.svg new file mode 100644 index 000000000..3de27826d --- /dev/null +++ b/frontend/src/assets/images/icons/close_button_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/images/icons/location_icon.svg b/frontend/src/assets/images/icons/location_icon.svg index 3d9551bfa..0af259202 100644 --- a/frontend/src/assets/images/icons/location_icon.svg +++ b/frontend/src/assets/images/icons/location_icon.svg @@ -1,4 +1,3 @@ - - - + + diff --git a/frontend/src/assets/images/icons/marker.svg b/frontend/src/assets/images/icons/marker.svg new file mode 100644 index 000000000..ecdc9a687 --- /dev/null +++ b/frontend/src/assets/images/icons/marker.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/icons/subscribe_button_icon.svg b/frontend/src/assets/images/icons/subscribe_button_icon.svg new file mode 100644 index 000000000..66a9b886d --- /dev/null +++ b/frontend/src/assets/images/icons/subscribe_button_icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/components/application/modals/ApplicationSelectModal.tsx b/frontend/src/components/application/modals/ApplicationSelectModal.tsx index 75109cfa7..7153e3ac0 100644 --- a/frontend/src/components/application/modals/ApplicationSelectModal.tsx +++ b/frontend/src/components/application/modals/ApplicationSelectModal.tsx @@ -1,5 +1,5 @@ +import Modal from '@/components/common/Modal/Modal'; import ModalLayout from '@/components/common/Modal/ModalLayout'; -import PortalModal from '@/components/common/Modal/PortalModal'; import { ApplicationForm } from '@/types/application'; import * as Styled from './ApplicationSelectModal.styles'; @@ -48,14 +48,14 @@ const ApplicationSelectModal = ({ onOptionSelect, }: ApplicationSelectModalProps) => { return ( - + - + ); }; diff --git a/frontend/src/components/common/Button/Button.tsx b/frontend/src/components/common/Button/Button.tsx index 61fe7eba5..e969ec10a 100644 --- a/frontend/src/components/common/Button/Button.tsx +++ b/frontend/src/components/common/Button/Button.tsx @@ -1,71 +1,62 @@ +import type { ButtonHTMLAttributes } from 'react'; import styled, { css, keyframes } from 'styled-components'; -export interface ButtonProps { +export interface ButtonProps extends ButtonHTMLAttributes { width?: string; - children: React.ReactNode; - type?: string; - onClick?: () => void; animated?: boolean; - disabled?: boolean; - className?: string; } const pulse = keyframes` - 0% { transform: scale(1); background-color: #3a3a3a; } - 50% { transform: scale(1.05); background-color: #505050; } - 100% { transform: scale(1); background-color: #3a3a3a; } + 0% { transform: scale(1); } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); } `; -const StyledButton = styled.button` - background-color: #3a3a3a; - color: #ffffff; +const StyledButton = styled.button<{ $animated: boolean; $width?: string }>` + display: inline-flex; + align-items: center; + justify-content: center; + background-color: ${({ theme }) => theme.colors.gray[900]}; + color: ${({ theme }) => theme.colors.base.white}; height: 42px; - border-radius: 10px; + padding: 0 16px; border: none; - font-weight: 600; - font-size: 16px; + border-radius: 10px; + font-size: ${({ theme }) => theme.typography.paragraph.p2.size}; + font-weight: ${({ theme }) => theme.typography.paragraph.p2.weight}; cursor: pointer; transition: background-color 0.2s; - width: ${({ width }) => width || 'auto'}; + width: ${({ $width }) => $width ?? 'auto'}; - &:hover { - background-color: #333333; - ${({ animated }) => - animated && + &:hover:not(:disabled) { + background-color: ${({ theme }) => theme.colors.gray[800]}; + ${({ $animated }) => + $animated && css` animation: ${pulse} 0.4s ease-in-out; `} } - &:active { - transform: ${({ animated }) => (animated ? 'scale(0.95)' : 'none')}; + &:active:not(:disabled) { + transform: ${({ $animated }) => ($animated ? 'scale(0.95)' : 'none')}; } &:disabled { - background-color: #cccccc; /* 비활성화된 느낌의 회색 */ - color: #666666; - cursor: not-allowed; /* 클릭할 수 없음을 나타내는 커서 */ + background-color: ${({ theme }) => theme.colors.gray[500]}; + color: ${({ theme }) => theme.colors.gray[600]}; + cursor: not-allowed; opacity: 0.7; } `; const Button = ({ width, - children, - onClick, - type, animated = false, - disabled = false, - className, + type = 'button', + children, + ...rest }: ButtonProps) => ( - + {children} ); diff --git a/frontend/src/components/common/Filter/Filter.styles.ts b/frontend/src/components/common/Filter/Filter.styles.ts index cfe333320..44dbf0fd3 100644 --- a/frontend/src/components/common/Filter/Filter.styles.ts +++ b/frontend/src/components/common/Filter/Filter.styles.ts @@ -2,8 +2,8 @@ import styled from 'styled-components'; import Button from '@/components/common/Button/Button'; import { theme } from '@/styles/theme'; -export const FilterListContainer = styled.div` - margin-top: 56px; +export const FilterListContainer = styled.div<{ $isWebview?: boolean }>` + margin-top: ${({ $isWebview }) => ($isWebview ? '0' : '56px')}; display: flex; flex-direction: row; padding: 10px 20px; diff --git a/frontend/src/components/common/Filter/Filter.tsx b/frontend/src/components/common/Filter/Filter.tsx index 1ce54b02f..db625e605 100644 --- a/frontend/src/components/common/Filter/Filter.tsx +++ b/frontend/src/components/common/Filter/Filter.tsx @@ -4,11 +4,16 @@ import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useDevice from '@/hooks/useDevice'; import * as Styled from './Filter.styles'; -const FILTER_OPTIONS = [ +const WEB_FILTER_OPTIONS = [ { label: '동아리', path: '/' }, { label: '홍보', path: '/promotions' }, ] as const; +const WEBVIEW_FILTER_OPTIONS = [ + { label: '동아리', path: '/webview/main' }, + { label: '홍보', path: '/webview/promotions' }, +] as const; + interface FilterProps { alwaysVisible?: boolean; hasNotification: boolean; @@ -19,24 +24,24 @@ const Filter = ({ alwaysVisible = false, hasNotification }: FilterProps) => { const navigate = useNavigate(); const { pathname } = useLocation(); const trackEvent = useMixpanelTrack(); - const shouldShow = alwaysVisible || isMobile; - const handleFilterOptionClick = (path: string) => { - trackEvent(USER_EVENT.FILTER_OPTION_CLICKED, { - path: path, - }); + const isWebview = pathname.startsWith('/webview'); + const filterOptions = isWebview ? WEBVIEW_FILTER_OPTIONS : WEB_FILTER_OPTIONS; + const shouldShow = alwaysVisible || isMobile || isWebview; + const handleFilterOptionClick = (path: string) => { + trackEvent(USER_EVENT.FILTER_OPTION_CLICKED, { path }); navigate(path); }; return ( <> {shouldShow && ( - - {FILTER_OPTIONS.map((filter) => ( + + {filterOptions.map((filter) => ( { - const trackEvent = useMixpanelTrack(); const location = useLocation(); const [isMenuOpen, setIsMenuOpen] = useState(false); const isScrolled = useScrollDetection(); - const { isMobile, isTablet, isLaptop, isDesktop } = useDevice(); + const isVisible = useHeaderVisibility(showOn, hideOn); const { handleHomeClick, handleIntroduceClick, handleClubUnionClick, handlePromotionClick, + handleMenuClose, } = useHeaderNavigation(); const isAdminPage = location.pathname.startsWith('/admin'); const isAdminLoginPage = location.pathname.startsWith('/admin/login'); - const isWebView = isInAppWebView(); - - const getCurrentDeviceTypes = (): DeviceType[] => { - const types: DeviceType[] = []; - if (isMobile) types.push('mobile'); - if (isTablet) types.push('tablet'); - if (isLaptop) types.push('laptop'); - if (isDesktop) types.push('desktop'); - if (isWebView) types.push('webview'); - return types; - }; - - const shouldRender = (): boolean => { - const currentTypes = getCurrentDeviceTypes(); - - if (hideOn) { - return !hideOn.some((type) => currentTypes.includes(type)); - } - if (showOn) { - return showOn.some((type) => currentTypes.includes(type)); - } - - return true; - }; - - if (!shouldRender()) { + if (!isVisible) { return null; } @@ -80,9 +51,14 @@ const Header = ({ showOn, hideOn }: HeaderProps) => { const closeMenu = () => { setIsMenuOpen(false); - trackEvent(USER_EVENT.MOBILE_MENU_DELETE_BUTTON_CLICKED); }; - const toggleMenu = () => setIsMenuOpen((prev) => !prev); + const toggleMenu = () => { + setIsMenuOpen((prev) => { + const next = !prev; + if (prev && !next) handleMenuClose(); + return next; + }); + }; return ( diff --git a/frontend/src/components/common/Modal/PortalModal.stories.tsx b/frontend/src/components/common/Modal/Modal.stories.tsx similarity index 87% rename from frontend/src/components/common/Modal/PortalModal.stories.tsx rename to frontend/src/components/common/Modal/Modal.stories.tsx index 12702dc06..aa04a8405 100644 --- a/frontend/src/components/common/Modal/PortalModal.stories.tsx +++ b/frontend/src/components/common/Modal/Modal.stories.tsx @@ -1,11 +1,11 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import Button from '../Button/Button'; -import PortalModal from './PortalModal'; +import Modal from './Modal'; const meta = { - title: 'Components/Common/PortalModal', - component: PortalModal, + title: 'Components/Common/Modal', + component: Modal, parameters: { layout: 'centered', }, @@ -27,7 +27,7 @@ const meta = { description: '모달 내부에 렌더링될 컨텐츠입니다.', }, }, -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; @@ -69,12 +69,12 @@ export const Default: Story = { return ( <> - + - + ); }, @@ -100,12 +100,12 @@ export const NoBackdropClose: Story = { <> - + - + ); }, diff --git a/frontend/src/components/common/Modal/Modal.tsx b/frontend/src/components/common/Modal/Modal.tsx new file mode 100644 index 000000000..eae266cd7 --- /dev/null +++ b/frontend/src/components/common/Modal/Modal.tsx @@ -0,0 +1,50 @@ +import { MouseEvent, ReactNode, useEffect } from 'react'; +import Portal from '../Portal/Portal'; +import * as Styled from './Modal.styles'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + closeOnBackdrop?: boolean; +} + +const Modal = ({ + isOpen, + onClose, + children, + closeOnBackdrop = true, +}: ModalProps) => { + useEffect(() => { + if (!isOpen) return; + + document.body.style.overflow = 'hidden'; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.body.style.overflow = ''; + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( + + + ) => e.stopPropagation()} + > + {children} + + + + ); +}; + +export default Modal; diff --git a/frontend/src/components/common/Modal/PortalModal.tsx b/frontend/src/components/common/Modal/PortalModal.tsx deleted file mode 100644 index c2b5705b3..000000000 --- a/frontend/src/components/common/Modal/PortalModal.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { MouseEvent, ReactNode, useEffect } from 'react'; -import { createPortal } from 'react-dom'; -import * as Styled from './Modal.styles'; - -interface PortalModalProps { - isOpen: boolean; - onClose: () => void; - children: ReactNode; - closeOnBackdrop?: boolean; -} - -const PortalModal = ({ - isOpen, - onClose, - children, - closeOnBackdrop = true, -}: PortalModalProps) => { - useEffect(() => { - if (isOpen) document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = ''; - }; - }, [isOpen]); - - if (!isOpen) return null; - - const modalRoot = document.getElementById('modal-root'); - if (!modalRoot) return null; - - return createPortal( - { - if (closeOnBackdrop) onClose(); - }} - > - ) => e.stopPropagation()} - > - {children} - - , - modalRoot, - ); -}; - -export default PortalModal; diff --git a/frontend/src/components/common/Portal/Portal.tsx b/frontend/src/components/common/Portal/Portal.tsx new file mode 100644 index 000000000..974fe3ccc --- /dev/null +++ b/frontend/src/components/common/Portal/Portal.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +interface PortalProps { + children: ReactNode; + rootId?: string; +} + +const Portal = ({ children, rootId = 'modal-root' }: PortalProps) => { + const root = document.getElementById(rootId); + if (!root) return null; + return createPortal(children, root); +}; + +export default Portal; diff --git a/frontend/src/components/common/SearchField/SearchField.styles.ts b/frontend/src/components/common/SearchField/SearchField.styles.ts index ad8c7a678..2ce3e94aa 100644 --- a/frontend/src/components/common/SearchField/SearchField.styles.ts +++ b/frontend/src/components/common/SearchField/SearchField.styles.ts @@ -5,7 +5,8 @@ export const SearchBoxContainer = styled.form<{ $isFocused: boolean }>` display: flex; align-items: center; justify-content: center; - width: 345px; + width: 100%; + max-width: 345px; height: 40px; padding: 3px 20px; border: 1px solid transparent; @@ -16,7 +17,7 @@ export const SearchBoxContainer = styled.form<{ $isFocused: boolean }>` border-color: ${({ $isFocused }) => $isFocused ? 'rgba(255, 84, 20, 0.8)' : 'transparent'}; ${media.mobile} { - width: 255px; + max-width: 255px; height: 36px; padding: 6px 16px; } diff --git a/frontend/src/components/common/UnderlineTabs/UnderlineTabs.styles.ts b/frontend/src/components/common/UnderlineTabs/UnderlineTabs.styles.ts index da972e178..76913ae62 100644 --- a/frontend/src/components/common/UnderlineTabs/UnderlineTabs.styles.ts +++ b/frontend/src/components/common/UnderlineTabs/UnderlineTabs.styles.ts @@ -2,40 +2,40 @@ import styled from 'styled-components'; import { media } from '@/styles/mediaQuery'; import { colors } from '@/styles/theme/colors'; import { transitions } from '@/styles/theme/transitions'; +import { setTypography, typography } from '@/styles/theme/typography'; export const TabList = styled.div<{ $centerOnMobile: boolean }>` display: flex; - margin-bottom: 16px; - border-bottom: 1px solid ${colors.gray[200]}; + align-items: center; ${media.tablet} { padding: 0 20px; - } - - ${media.mobile} { justify-content: ${({ $centerOnMobile }) => $centerOnMobile ? 'center' : 'flex-start'}; + box-shadow: inset 0 -1px 0 ${colors.gray[300]}; } `; export const TabButton = styled.button<{ $active: boolean }>` - font-size: 14px; - font-weight: 700; - width: 167px; - height: 26px; + flex: 1; + ${({ $active }) => + setTypography($active ? typography.title.title6 : typography.paragraph.p3)}; padding-bottom: 4px; - color: ${({ $active }) => ($active ? colors.gray[800] : colors.gray[400])}; + color: ${({ $active }) => ($active ? colors.gray[800] : colors.gray[500])}; background: none; border: none; border-bottom: 2px solid - ${({ $active }) => ($active ? colors.gray[800] : colors.gray[400])}; + ${({ $active }) => ($active ? colors.gray[800] : colors.gray[500])}; cursor: pointer; transition: color ${transitions.duration.normal} ${transitions.easing.easeInOut}, border-color ${transitions.duration.normal} ${transitions.easing.easeInOut}; ${media.tablet} { - flex: 1; - width: auto; + max-width: none; + ${setTypography(typography.paragraph.p5)}; + font-weight: ${({ $active }) => ($active ? 700 : 500)}; + border-bottom-color: ${({ $active }) => + $active ? colors.gray[800] : 'transparent'}; } `; diff --git a/frontend/src/components/common/UnderlineTabs/UnderlineTabs.tsx b/frontend/src/components/common/UnderlineTabs/UnderlineTabs.tsx index 08c61a1d2..d54100c93 100644 --- a/frontend/src/components/common/UnderlineTabs/UnderlineTabs.tsx +++ b/frontend/src/components/common/UnderlineTabs/UnderlineTabs.tsx @@ -10,6 +10,7 @@ interface UnderlineTabsProps { activeKey: string; onTabClick: (tabKey: string) => void; centerOnMobile?: boolean; + className?: string; } const UnderlineTabs = ({ @@ -17,9 +18,10 @@ const UnderlineTabs = ({ activeKey, onTabClick, centerOnMobile = false, + className, }: UnderlineTabsProps) => { return ( - + {tabs.map((tab) => ( ; +} + +const InteractiveMapView = ({ + location, + clubName, + clubLogo, + active, + markerSize = 40, + bubbleFontSize = 13, + bubbleFontWeight = 700, + mapInstanceRef, +}: InteractiveMapViewProps) => { + const mapRef = useRef(null); + + useNaverMap(mapRef, location.lat, location.lng, { + active, + interactive: true, + markerSize, + bubbleText: '동아리방', + bubbleFontSize, + bubbleFontWeight, + mapInstanceRef, + }); + + const handleRecenter = useCallback(() => { + const map = mapInstanceRef.current; + if (map && window.naver) { + map.setCenter(new window.naver.maps.LatLng(location.lat, location.lng)); + } + }, [mapInstanceRef, location.lat, location.lng]); + + return ( + + + + + + + ); +}; + +export default InteractiveMapView; diff --git a/frontend/src/components/map/MapClubInfoCard/MapClubInfoCard.styles.ts b/frontend/src/components/map/MapClubInfoCard/MapClubInfoCard.styles.ts new file mode 100644 index 000000000..9970d072c --- /dev/null +++ b/frontend/src/components/map/MapClubInfoCard/MapClubInfoCard.styles.ts @@ -0,0 +1,82 @@ +import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; +import { setTypography, typography } from '@/styles/theme/typography'; + +export const Card = styled.div` + width: 357px; + background-color: ${colors.base.white}; + border-radius: 16px; + padding: 24px 24px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + gap: 12px; + box-sizing: border-box; + + ${media.tablet} { + width: 335px; + padding: 24px 16px; + } + + ${media.mobile} { + width: calc(100vw - 40px); + } +`; + +export const ClubLogo = styled.img` + width: 60px; + height: 60px; + border-radius: 12px; + object-fit: cover; + flex-shrink: 0; + background-color: ${colors.gray[200]}; +`; + +export const ClubInfo = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + overflow: hidden; +`; + +export const ClubName = styled.span` + ${setTypography(typography.title.title2)}; + color: ${colors.base.black}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: default; + user-select: none; + + ${media.tablet} { + ${setTypography(typography.title.title5)}; + } +`; + +export const LocationRow = styled.div` + display: flex; + align-items: center; + gap: 4px; + + img { + width: 11px; + height: 14px; + flex-shrink: 0; + } +`; + +export const LocationText = styled.span` + ${setTypography(typography.paragraph.p3)}; + color: ${colors.gray[600]}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: default; + user-select: none; + + ${media.tablet} { + ${setTypography(typography.paragraph.p6)}; + } +`; diff --git a/frontend/src/components/map/MapClubInfoCard/MapClubInfoCard.tsx b/frontend/src/components/map/MapClubInfoCard/MapClubInfoCard.tsx new file mode 100644 index 000000000..ff9648058 --- /dev/null +++ b/frontend/src/components/map/MapClubInfoCard/MapClubInfoCard.tsx @@ -0,0 +1,34 @@ +import locationIcon from '@/assets/images/icons/location_icon.svg'; +import DefaultLogo from '@/assets/images/logos/default_profile_image.svg'; +import * as Styled from './MapClubInfoCard.styles'; + +interface MapClubInfoCardProps { + logo?: string; + name: string; + building: string; + detailLocation: string; +} + +const MapClubInfoCard = ({ + logo, + name, + building, + detailLocation, +}: MapClubInfoCardProps) => { + return ( + + + + {name} + + 위치 아이콘 + + {building} {detailLocation} + + + + + ); +}; + +export default MapClubInfoCard; diff --git a/frontend/src/components/map/MapModal/MapModal.styles.ts b/frontend/src/components/map/MapModal/MapModal.styles.ts new file mode 100644 index 000000000..cf7feafe9 --- /dev/null +++ b/frontend/src/components/map/MapModal/MapModal.styles.ts @@ -0,0 +1,57 @@ +import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; + +const CONTROL_Z_INDEX = 10; + +export const Container = styled.div` + position: relative; + width: 86vw; + max-width: 1100px; + height: 73vh; + max-height: 820px; + border-radius: 20px; + overflow: hidden; + background-color: ${colors.base.white}; + margin-bottom: 40px; + + ${media.tablet} { + width: 100vw; + height: 100dvh; + max-width: none; + max-height: none; + border-radius: 0; + margin-bottom: 0; + } +`; + +export const ActionButton = styled.button` + position: absolute; + top: 16px; + right: 16px; + z-index: ${CONTROL_Z_INDEX}; + width: 36px; + height: 36px; + padding: 0; + border: none; + background-color: ${colors.base.white}; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + + ${media.tablet} { + top: calc(12px + var(--rn-safe-top, 0px)); + left: 16px; + right: auto; + } +`; + +export const ZoomControlsWrapper = styled.div` + position: absolute; + bottom: 50px; + right: 40px; + z-index: ${CONTROL_Z_INDEX}; +`; diff --git a/frontend/src/components/map/MapModal/MapModal.tsx b/frontend/src/components/map/MapModal/MapModal.tsx new file mode 100644 index 000000000..086b4c9ea --- /dev/null +++ b/frontend/src/components/map/MapModal/MapModal.tsx @@ -0,0 +1,67 @@ +import { useRef } from 'react'; +import CloseButtonIcon from '@/assets/images/icons/close_button_icon.svg?react'; +import PrevButtonIcon from '@/assets/images/icons/prev_button_icon.svg?react'; +import Modal from '@/components/common/Modal/Modal'; +import InteractiveMapView from '@/components/map/InteractiveMapView/InteractiveMapView'; +import MapZoomControls from '@/components/map/MapZoomControls/MapZoomControls'; +import { ClubLocation } from '@/constants/clubLocation'; +import { NaverMapInstance, useMapZoom } from '@/hooks/Map/useMapZoom'; +import useDevice from '@/hooks/useDevice'; +import * as Styled from './MapModal.styles'; + +interface MapModalProps { + isOpen: boolean; + onClose: () => void; + clubName: string; + clubLogo?: string; + location: ClubLocation; +} + +const MapModal = ({ + isOpen, + onClose, + clubName, + clubLogo, + location, +}: MapModalProps) => { + const { isMobile, isTablet } = useDevice(); + const isMobileView = isMobile || isTablet; + const mapInstanceRef = useRef(null); + const { zoomIn, zoomOut } = useMapZoom(mapInstanceRef); + + return ( + + + + + + {isMobileView ? ( + + ) : ( + + )} + + + {!isMobileView && ( + + + + )} + + + ); +}; + +export default MapModal; diff --git a/frontend/src/components/map/MapZoomControls/MapZoomControls.styles.ts b/frontend/src/components/map/MapZoomControls/MapZoomControls.styles.ts new file mode 100644 index 000000000..367a2b829 --- /dev/null +++ b/frontend/src/components/map/MapZoomControls/MapZoomControls.styles.ts @@ -0,0 +1,78 @@ +import styled from 'styled-components'; +import { colors } from '@/styles/theme/colors'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + background-color: ${colors.base.white}; + border-radius: 16px; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.1); + width: 40px; +`; + +export const Button = styled.button` + width: 40px; + height: 40px; + padding: 10px; + border: none; + background-color: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: ${colors.gray[100]}; + } + + &:first-child { + border-radius: 16px 16px 0 0; + } + + &:last-child { + border-radius: 0 0 16px 16px; + } +`; + +export const Divider = styled.div` + width: 40px; + height: 1px; + background-color: ${colors.gray[300]}; +`; + +export const PlusIcon = styled.span` + position: relative; + width: 18px; + height: 18px; + + &::before, + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + background-color: ${colors.gray[600]}; + border-radius: 1px; + } + + &::before { + width: 18px; + height: 2.5px; + transform: translate(-50%, -50%); + } + + &::after { + width: 2.5px; + height: 18px; + transform: translate(-50%, -50%); + } +`; + +export const MinusIcon = styled.span` + position: relative; + width: 18px; + height: 2.5px; + background-color: ${colors.gray[600]}; + border-radius: 1px; +`; diff --git a/frontend/src/components/map/MapZoomControls/MapZoomControls.tsx b/frontend/src/components/map/MapZoomControls/MapZoomControls.tsx new file mode 100644 index 000000000..3143d3abc --- /dev/null +++ b/frontend/src/components/map/MapZoomControls/MapZoomControls.tsx @@ -0,0 +1,22 @@ +import * as Styled from './MapZoomControls.styles'; + +interface MapZoomControlsProps { + onZoomIn: () => void; + onZoomOut: () => void; +} + +const MapZoomControls = ({ onZoomIn, onZoomOut }: MapZoomControlsProps) => { + return ( + + + + + + + + + + ); +}; + +export default MapZoomControls; diff --git a/frontend/src/components/map/NaverMap/NaverMap.styles.ts b/frontend/src/components/map/NaverMap/NaverMap.styles.ts new file mode 100644 index 000000000..8401e4695 --- /dev/null +++ b/frontend/src/components/map/NaverMap/NaverMap.styles.ts @@ -0,0 +1,12 @@ +import styled from 'styled-components'; + +export const MapContainer = styled.div` + width: 100%; + height: 100%; + + overflow: hidden; + + * { + cursor: default !important; + } +`; diff --git a/frontend/src/components/map/NaverMap/NaverMap.tsx b/frontend/src/components/map/NaverMap/NaverMap.tsx new file mode 100644 index 000000000..78f67f094 --- /dev/null +++ b/frontend/src/components/map/NaverMap/NaverMap.tsx @@ -0,0 +1,18 @@ +import { useRef } from 'react'; +import { ClubLocation } from '@/constants/clubLocation'; +import { useNaverMap } from '@/hooks/Map/useNaverMap'; +import * as Styled from './NaverMap.styles'; + +interface NaverMapProps { + location: Pick; +} + +const NaverMap = ({ location }: NaverMapProps) => { + const mapRef = useRef(null); + + useNaverMap(mapRef, location.lat, location.lng, { interactive: false }); + + return ; +}; + +export default NaverMap; diff --git a/frontend/src/constants/clubLocation.ts b/frontend/src/constants/clubLocation.ts new file mode 100644 index 000000000..e54b948bf --- /dev/null +++ b/frontend/src/constants/clubLocation.ts @@ -0,0 +1,495 @@ +export interface ClubLocation { + clubName: string; + lat: number; + lng: number; + building: string; + detailLocation: string; +} + +export const clubLocations = [ + // 공연 1분과 + { + clubName: 'PKNUO', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 208호', + }, + { + clubName: 'UCDC', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 104호', + }, + { + clubName: '네오쇼크', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 108호', + }, + { + clubName: '백경극예술연구회', + lat: 35.132367, + lng: 129.106974, + building: '한울관(E31)', + detailLocation: '301호', + }, + { + clubName: '보블리스', + lat: 35.132367, + lng: 129.106974, + building: '한울관(E31)', + detailLocation: '305호', + }, + { + clubName: '전통예술연구회 터', + lat: 35.132367, + lng: 129.106974, + building: '한울관(E31)', + detailLocation: '307호', + }, + + // 공연 2분과 + { + clubName: '매니아', + lat: 35.132367, + lng: 129.106974, + building: '한울관(E31)', + detailLocation: '308호', + }, + { + clubName: '모비딕스', + lat: 35.132367, + lng: 129.106974, + building: '한울관(E31)', + detailLocation: '201호', + }, + { + clubName: '백경클래식기타연구회', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 109호', + }, + { + clubName: '송웨이브', + lat: 35.132367, + lng: 129.106974, + building: '한울관(E31)', + detailLocation: '207호', + }, + { + clubName: '쇳물결', + lat: 35.132367, + lng: 129.106974, + building: '한울관(E31)', + detailLocation: '202호', + }, + { + clubName: '씨사운드', + lat: 35.132367, + lng: 129.106974, + building: '한울관(E31)', + detailLocation: '206호', + }, + { + clubName: '울림', + lat: 35.132367, + lng: 129.106974, + building: '한울관(E31)', + detailLocation: '303호', + }, + { + clubName: '테크니칼', + lat: 35.132367, + lng: 129.106974, + building: '한울관(E31)', + detailLocation: '302호', + }, + { + clubName: '한누리', + lat: 35.132367, + lng: 129.106974, + building: '한울관(E31)', + detailLocation: '306호', + }, + + // 운동 1분과 + { + clubName: '거터', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 221호', + }, + { + clubName: '디그', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 309호', + }, + { + clubName: '모비딕', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 106호', + }, + { + clubName: '바구니', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 220호', + }, + { + clubName: '버드', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 214호', + }, + { + clubName: '스매싱', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 306', + }, + { + clubName: '스타피쉬-농구', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 105호', + }, + { + clubName: '스타피쉬-축구', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 101호', + }, + { + clubName: '어택', + lat: 35.131861, + lng: 129.106902, + building: '행복기숙사 앞 테니스장 입구 쪽', + detailLocation: '1층 테니스부', + }, + { + clubName: '홍백', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 201호', + }, + { + clubName: '후라', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 107호', + }, + { + clubName: '웨일즈', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 222호', + }, + + // 운동 2분과 + { + clubName: '리얼겟', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 211호', + }, + { + clubName: '돼지', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 209호', + }, + { + clubName: '부경다이버', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 320호~322호', + }, + { + clubName: '산악부', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 208호', + }, + { + clubName: '조나단', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 207호', + }, + { + clubName: '조정부', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 105호', + }, + { + clubName: '프라우드', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 204호', + }, + // 한판 + + // 봉사분과 + { + clubName: 'RCY', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 305호', + }, + { + clubName: '남천 로타렉트', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 213호', + }, + { + clubName: '동반', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 314호', + }, + { + clubName: '미담장학회', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 313호', + }, + { + clubName: '민심사랑', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 302호', + }, + { + clubName: '소리빛깔', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 319호', + }, + { + clubName: '절영회', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 215호', + }, + { + clubName: '청심회', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 206호', + }, + { + clubName: '피어드림', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 104호', + }, + + // 종교분과 + { + clubName: 'CCC', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 204호', + }, + { + clubName: 'IVF', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 107호', + }, + { + clubName: 'JDM', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 108호', + }, + { + clubName: 'SFC', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 206호', + }, + { + clubName: '불교학생회', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 210호', + }, + + // 취미교양분과 + { + clubName: '300', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 304호', + }, + { + clubName: 'PAS', + lat: 35.132367, + lng: 129.106974, + building: '한울관(E31)', + detailLocation: '203호', + }, + { + clubName: '나불 아뜨리에', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 107호', + }, + { + clubName: '백경 유스호텔', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 307호', + }, + { + clubName: '수석회', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 210호', + }, + { + clubName: '입자', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 110호', + }, + { + clubName: '짚신', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 205호', + }, + { + clubName: '차사랑', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 207호', + }, + { + clubName: '포시즌', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 109호', + }, + + // 학술분과 + { + clubName: 'CERT-IS', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 202호', + }, + { + clubName: 'O.S.T', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 308호', + }, + { + clubName: 'SIC', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 209호', + }, + { + clubName: 'TIME', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 103호', + }, + { + clubName: 'WAP', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 205호', + }, + { + clubName: '그린드림', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 212호', + }, + { + clubName: '아카데미', + lat: 35.131673, + lng: 129.105008, + building: '한솔관(E16)', + detailLocation: 'B동 203호', + }, + { + clubName: '일본문화연구회', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 303호', + }, + { + clubName: '집현전', + lat: 35.131626, + lng: 129.104263, + building: '한솔관(E16)', + detailLocation: 'A동 312호', + }, + { + clubName: '플레이아데스', + lat: 35.134059, + lng: 129.106349, + building: '나비센터(수협은행)', + detailLocation: '4층', + }, +] as const; diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts index 6e36f2d37..afc957bbc 100644 --- a/frontend/src/constants/eventName.ts +++ b/frontend/src/constants/eventName.ts @@ -64,6 +64,13 @@ export const USER_EVENT = { // 홍보 PROMOTION_BUTTON_CLICKED: 'Promotion Button Clicked', PROMOTION_CARD_CLICKED: 'Promotion Card Clicked', + PROMOTION_CLUB_CTA_CLICKED: 'Promotion Club CTA Clicked', + + WEBVIEW_SUBSCRIBE_TOGGLED: 'Webview Subscribe Toggled', +} as const; + +export const WEBVIEW_LINK_TARGET = { + CLUB_FESTIVAL: 'CLUB_FESTIVAL', } as const; export const ADMIN_EVENT = { @@ -121,6 +128,8 @@ export const PAGE_VIEW = { PROMOTION_LIST_PAGE: '홍보 목록 페이지', PROMOTION_DETAIL_PAGE: '홍보 상세 페이지', + WEBVIEW_MAIN_PAGE: 'WebviewMainPage', + // 관리자 LOGIN_PAGE: '로그인페이지', CLUB_INTRO_EDIT_PAGE: '동아리 소개 수정 페이지', diff --git a/frontend/src/constants/queryKeys.ts b/frontend/src/constants/queryKeys.ts index 54b6f231c..18de376cf 100644 --- a/frontend/src/constants/queryKeys.ts +++ b/frontend/src/constants/queryKeys.ts @@ -26,6 +26,8 @@ export const queryKeys = { category: string, division: string, ) => ['clubs', keyword, recruitmentStatus, category, division] as const, + suggestions: (keyword: string) => + ['clubs', 'suggestions', keyword] as const, }, promotion: { all: ['promotions'] as const, @@ -36,4 +38,8 @@ export const queryKeys = { list: (type: 'WEB' | 'APP_HOME' | 'WEB_MOBILE') => ['banner', type] as const, }, + game: { + all: ['game'] as const, + ranking: () => ['game', 'ranking'] as const, + }, } as const; diff --git a/frontend/src/experiments/ExperimentRepository.ts b/frontend/src/experiments/ExperimentRepository.ts index d9d9fb23b..cca717f6e 100644 --- a/frontend/src/experiments/ExperimentRepository.ts +++ b/frontend/src/experiments/ExperimentRepository.ts @@ -1,3 +1,4 @@ +import mixpanel from 'mixpanel-browser'; import type { ExperimentAssignments, ExperimentDefinition, @@ -71,9 +72,14 @@ class ExperimentRepository { const isValidExisting = !!existing && experiment.variants.includes(existing); - if (isValidExisting) return; + if (isValidExisting) { + mixpanel.register({ [experiment.key]: existing }); + return; + } - assignments[experiment.key] = pickWeightedVariant(experiment); + const variant = pickWeightedVariant(experiment); + assignments[experiment.key] = variant; + mixpanel.register({ [experiment.key]: variant }); }); writeAssignments(assignments); diff --git a/frontend/src/hooks/Header/useHeaderNavigation.ts b/frontend/src/hooks/Header/useHeaderNavigation.ts index 3097f4e3a..1b1722f84 100644 --- a/frontend/src/hooks/Header/useHeaderNavigation.ts +++ b/frontend/src/hooks/Header/useHeaderNavigation.ts @@ -37,12 +37,17 @@ const useHeaderNavigation = () => { trackEvent(USER_EVENT.ADMIN_BUTTON_CLICKED); }, [navigate, trackEvent]); + const handleMenuClose = useCallback(() => { + trackEvent(USER_EVENT.MOBILE_MENU_DELETE_BUTTON_CLICKED); + }, [trackEvent]); + return { handleHomeClick, handleIntroduceClick, handleClubUnionClick, handlePromotionClick, handleAdminClick, + handleMenuClose, }; }; diff --git a/frontend/src/hooks/Header/useHeaderVisibility.test.ts b/frontend/src/hooks/Header/useHeaderVisibility.test.ts new file mode 100644 index 000000000..f51bbd995 --- /dev/null +++ b/frontend/src/hooks/Header/useHeaderVisibility.test.ts @@ -0,0 +1,193 @@ +import { renderHook } from '@testing-library/react'; +import useDevice from '@/hooks/useDevice'; +import { DeviceType } from '@/types/device'; +import isInAppWebView from '@/utils/isInAppWebView'; +import useHeaderVisibility from './useHeaderVisibility'; + +jest.mock('@/hooks/useDevice'); +jest.mock('@/utils/isInAppWebView'); + +const mockUseDevice = useDevice as jest.Mock; +const mockIsInAppWebView = isInAppWebView as jest.Mock; + +const setupDevice = ( + overrides: Partial< + Record<'isMobile' | 'isTablet' | 'isLaptop' | 'isDesktop', boolean> + > = {}, +) => { + mockUseDevice.mockReturnValue({ + isMobile: false, + isTablet: false, + isLaptop: false, + isDesktop: true, + ...overrides, + }); +}; + +describe('useHeaderVisibility 테스트', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDevice(); + mockIsInAppWebView.mockReturnValue(false); + }); + + describe('props 없을 때 (기본값)', () => { + it('showOn, hideOn 모두 없으면 항상 true를 반환한다', () => { + // Given & When + const { result } = renderHook(() => useHeaderVisibility()); + + // Then + expect(result.current).toBe(true); + }); + }); + + describe('hideOn 테스트', () => { + it('현재 디바이스가 hideOn에 포함되면 false를 반환한다', () => { + // Given + setupDevice({ isDesktop: true }); + + // When + const { result } = renderHook(() => + useHeaderVisibility(undefined, ['desktop']), + ); + + // Then + expect(result.current).toBe(false); + }); + + it('현재 디바이스가 hideOn에 포함되지 않으면 true를 반환한다', () => { + // Given + setupDevice({ isMobile: true, isDesktop: false }); + + // When + const { result } = renderHook(() => + useHeaderVisibility(undefined, ['desktop']), + ); + + // Then + expect(result.current).toBe(true); + }); + + it('hideOn에 여러 디바이스가 있을 때 하나라도 일치하면 false를 반환한다', () => { + // Given + setupDevice({ isTablet: true, isDesktop: false }); + + // When + const { result } = renderHook(() => + useHeaderVisibility(undefined, ['mobile', 'tablet'] as DeviceType[]), + ); + + // Then + expect(result.current).toBe(false); + }); + + it('webview 환경에서 hideOn에 webview가 포함되면 false를 반환한다', () => { + // Given + mockIsInAppWebView.mockReturnValue(true); + + // When + const { result } = renderHook(() => + useHeaderVisibility(undefined, ['webview']), + ); + + // Then + expect(result.current).toBe(false); + }); + }); + + describe('showOn 테스트', () => { + it('현재 디바이스가 showOn에 포함되면 true를 반환한다', () => { + // Given + setupDevice({ isDesktop: true }); + + // When + const { result } = renderHook(() => useHeaderVisibility(['desktop'])); + + // Then + expect(result.current).toBe(true); + }); + + it('현재 디바이스가 showOn에 포함되지 않으면 false를 반환한다', () => { + // Given + setupDevice({ isMobile: true, isDesktop: false }); + + // When + const { result } = renderHook(() => useHeaderVisibility(['desktop'])); + + // Then + expect(result.current).toBe(false); + }); + + it('showOn에 여러 디바이스가 있을 때 하나라도 일치하면 true를 반환한다', () => { + // Given + setupDevice({ isTablet: true, isDesktop: false }); + + // When + const { result } = renderHook(() => + useHeaderVisibility(['mobile', 'tablet'] as DeviceType[]), + ); + + // Then + expect(result.current).toBe(true); + }); + + it('webview 환경에서 showOn에 webview가 포함되면 true를 반환한다', () => { + // Given + setupDevice({ isDesktop: false }); + mockIsInAppWebView.mockReturnValue(true); + + // When + const { result } = renderHook(() => useHeaderVisibility(['webview'])); + + // Then + expect(result.current).toBe(true); + }); + }); + + describe('빈 배열 경계 조건', () => { + it('hideOn=[]일 때 showOn이 무시되지 않고 평가된다', () => { + // Given + setupDevice({ isDesktop: true }); + + // When + const { result } = renderHook(() => useHeaderVisibility(['desktop'], [])); + + // Then + expect(result.current).toBe(true); + }); + + it('showOn=[]일 때 true를 반환한다 (기본값 fallback)', () => { + // Given + setupDevice({ isDesktop: true }); + + // When + const { result } = renderHook(() => useHeaderVisibility([])); + + // Then + expect(result.current).toBe(true); + }); + + it('hideOn=[], showOn=[]일 때 true를 반환한다', () => { + // Given & When + const { result } = renderHook(() => useHeaderVisibility([], [])); + + // Then + expect(result.current).toBe(true); + }); + }); + + describe('hideOn이 showOn보다 우선순위가 높다', () => { + it('hideOn과 showOn이 동시에 있을 때 hideOn이 우선 적용된다', () => { + // Given + setupDevice({ isDesktop: true }); + + // When + const { result } = renderHook(() => + useHeaderVisibility(['desktop'], ['desktop']), + ); + + // Then + expect(result.current).toBe(false); + }); + }); +}); diff --git a/frontend/src/hooks/Header/useHeaderVisibility.ts b/frontend/src/hooks/Header/useHeaderVisibility.ts new file mode 100644 index 000000000..2b0e7a14e --- /dev/null +++ b/frontend/src/hooks/Header/useHeaderVisibility.ts @@ -0,0 +1,22 @@ +import useDevice from '@/hooks/useDevice'; +import { DeviceType } from '@/types/device'; +import isInAppWebView from '@/utils/isInAppWebView'; + +const useHeaderVisibility = (showOn?: DeviceType[], hideOn?: DeviceType[]) => { + const { isMobile, isTablet, isLaptop, isDesktop } = useDevice(); + const isWebView = isInAppWebView(); + + const currentTypes: DeviceType[] = [ + isMobile && 'mobile', + isTablet && 'tablet', + isLaptop && 'laptop', + isDesktop && 'desktop', + isWebView && 'webview', + ].filter(Boolean) as DeviceType[]; + + if (hideOn?.length) return !hideOn.some((t) => currentTypes.includes(t)); + if (showOn?.length) return showOn.some((t) => currentTypes.includes(t)); + return true; +}; + +export default useHeaderVisibility; diff --git a/frontend/src/hooks/Map/useMapZoom.ts b/frontend/src/hooks/Map/useMapZoom.ts new file mode 100644 index 000000000..d387add25 --- /dev/null +++ b/frontend/src/hooks/Map/useMapZoom.ts @@ -0,0 +1,24 @@ +import { RefObject, useCallback } from 'react'; + +export interface NaverMapInstance { + getZoom: () => number; + setZoom: (zoom: number) => void; + setCenter: (latlng: unknown) => void; + destroy: () => void; +} + +export const useMapZoom = ( + mapInstanceRef: RefObject, +) => { + const zoomIn = useCallback(() => { + const map = mapInstanceRef.current; + if (map) map.setZoom(map.getZoom() + 1); + }, [mapInstanceRef]); + + const zoomOut = useCallback(() => { + const map = mapInstanceRef.current; + if (map) map.setZoom(map.getZoom() - 1); + }, [mapInstanceRef]); + + return { zoomIn, zoomOut }; +}; diff --git a/frontend/src/hooks/Map/useNaverMap.ts b/frontend/src/hooks/Map/useNaverMap.ts new file mode 100644 index 000000000..c3e2ae44a --- /dev/null +++ b/frontend/src/hooks/Map/useNaverMap.ts @@ -0,0 +1,138 @@ +import { RefObject, useEffect } from 'react'; +import markerIcon from '@/assets/images/icons/marker.svg'; +import { colors } from '@/styles/theme/colors'; +import { loadNaverMapScript } from '@/utils/loadNaverMapScript'; +import { NaverMapInstance } from './useMapZoom'; + +interface UseNaverMapOptions { + active?: boolean; + interactive?: boolean; + markerSize?: number; + bubbleText?: string; + bubbleFontSize?: number; + bubbleFontWeight?: number; + mapInstanceRef?: RefObject; +} + +const buildMarkerContent = ( + markerSize: number, + bubbleText?: string, + bubbleFontSize = 13, + bubbleFontWeight = 700, +): string => { + const image = ``; + + if (!bubbleText) return image; + + return ` +
+
+
${bubbleText}
+
+
+ ${image} +
+ `; +}; + +export const useNaverMap = ( + mapRef: RefObject, + lat: number, + lng: number, + options?: UseNaverMapOptions, +) => { + const { + active = true, + interactive = true, + markerSize = 40, + bubbleText, + bubbleFontSize, + bubbleFontWeight, + mapInstanceRef: externalRef, + } = options ?? {}; + + useEffect(() => { + if (!active) return; + + let mapInstance: NaverMapInstance | null = null; + + loadNaverMapScript().then(() => { + if (!mapRef.current || !window.naver) return; + + const { naver } = window; + const position = new naver.maps.LatLng(lat, lng); + + mapInstance = new naver.maps.Map(mapRef.current, { + center: position, + zoom: 17, + logoControl: false, + mapDataControl: false, + scaleControl: false, + draggable: interactive, + scrollWheel: interactive, + keyboardShortcuts: interactive, + disableDoubleClickZoom: !interactive, + pinchZoom: interactive, + }); + + if (externalRef) { + externalRef.current = mapInstance; + } + + new naver.maps.Marker({ + position, + map: mapInstance, + icon: { + content: buildMarkerContent( + markerSize, + bubbleText, + bubbleFontSize, + bubbleFontWeight, + ), + anchor: new naver.maps.Point(markerSize / 2, markerSize), + }, + }); + }); + + return () => { + mapInstance?.destroy(); + if (externalRef) externalRef.current = null; + }; + }, [ + mapRef, + lat, + lng, + active, + interactive, + markerSize, + bubbleText, + bubbleFontSize, + bubbleFontWeight, + externalRef, + ]); +}; diff --git a/frontend/src/hooks/Queries/useClub.ts b/frontend/src/hooks/Queries/useClub.ts index 976015018..5d6bdcfa7 100644 --- a/frontend/src/hooks/Queries/useClub.ts +++ b/frontend/src/hooks/Queries/useClub.ts @@ -90,6 +90,28 @@ export const useGetCardList = ({ }); }; +export const useValidateClubName = () => { + const queryClient = useQueryClient(); + return async (name: string) => { + const { clubs } = await queryClient.ensureQueryData({ + queryKey: queryKeys.club.suggestions(name), + queryFn: () => getClubList(name), + staleTime: 30 * 1000, + }); + return clubs.some((c) => c.name === name); + }; +}; + +export const useClubSuggestions = (keyword: string) => { + return useQuery({ + queryKey: queryKeys.club.suggestions(keyword), + queryFn: () => getClubList(keyword), + enabled: !!keyword.trim(), + staleTime: 30 * 1000, + select: (data) => data.clubs.map((c) => c.name), + }); +}; + export const useUpdateClubDescription = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/Queries/useGame.ts b/frontend/src/hooks/Queries/useGame.ts new file mode 100644 index 000000000..7aab99441 --- /dev/null +++ b/frontend/src/hooks/Queries/useGame.ts @@ -0,0 +1,22 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { getGameRanking, postGameClick } from '@/apis/game'; +import { queryKeys } from '@/constants/queryKeys'; + +export const useGameRanking = () => { + return useQuery({ + queryKey: queryKeys.game.ranking(), + queryFn: getGameRanking, + refetchInterval: 2000, + staleTime: 0, + }); +}; + +export const useClickGame = () => { + return useMutation({ + mutationFn: ({ clubName, count }: { clubName: string; count: number }) => + postGameClick(clubName, count), + onError: (error) => { + console.error('Error clicking game:', error); + }, + }); +}; diff --git a/frontend/src/hooks/Queries/usePromotionNotification.ts b/frontend/src/hooks/Queries/usePromotionNotification.ts index d672232fa..bcc01e82d 100644 --- a/frontend/src/hooks/Queries/usePromotionNotification.ts +++ b/frontend/src/hooks/Queries/usePromotionNotification.ts @@ -18,7 +18,7 @@ const usePromotionNotification = () => { const latestTime = getLatestPromotionTime(data); const lastChecked = getLastCheckedTime(); - if (pathname === '/promotions') { + if (pathname === '/promotions' || pathname === '/webview/promotions') { setLastCheckedTime(latestTime); setHasNotification(false); return; diff --git a/frontend/src/hooks/useNavigator.test.ts b/frontend/src/hooks/useNavigator.test.ts index 0c3c18cd3..299a8b2cd 100644 --- a/frontend/src/hooks/useNavigator.test.ts +++ b/frontend/src/hooks/useNavigator.test.ts @@ -1,10 +1,24 @@ import { useNavigate } from 'react-router-dom'; import { renderHook, RenderHookResult } from '@testing-library/react'; import useNavigator from '@/hooks/useNavigator'; +import isInAppWebView from '@/utils/isInAppWebView'; +import { + requestNavigateWebview, + requestOpenExternalUrl, +} from '@/utils/webviewBridge'; jest.mock('react-router-dom', () => ({ useNavigate: jest.fn(), })); +jest.mock('@/utils/isInAppWebView'); +jest.mock('@/utils/webviewBridge', () => ({ + requestNavigateWebview: jest.fn(), + requestOpenExternalUrl: jest.fn(), +})); + +const mockIsInAppWebView = isInAppWebView as jest.Mock; +const mockRequestNavigateWebview = requestNavigateWebview as jest.Mock; +const mockRequestOpenExternalUrl = requestOpenExternalUrl as jest.Mock; describe('useNavigator - 사용자가 링크를 클릭했을 때', () => { const mockNavigate = jest.fn(); @@ -14,13 +28,14 @@ describe('useNavigator - 사용자가 링크를 클릭했을 때', () => { beforeEach(() => { jest.clearAllMocks(); (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + mockIsInAppWebView.mockReturnValue(false); Object.defineProperty(window, 'location', { writable: true, value: { href: '' }, }); + window.open = jest.fn(); - // given handleLink = renderHook(() => useNavigator()); }); @@ -33,19 +48,15 @@ describe('useNavigator - 사용자가 링크를 클릭했을 때', () => { describe('링크가 비어있으면', () => { it('아무 페이지로도 이동하지 않는다', () => { - // When handleLink.result.current(''); - // Then expect(mockNavigate).not.toHaveBeenCalled(); expect(window.location.href).toBe(''); }); it('공백만 있는 링크도 이동하지 않는다', () => { - // When handleLink.result.current(' '); - // Then expect(mockNavigate).not.toHaveBeenCalled(); expect(window.location.href).toBe(''); }); @@ -58,47 +69,91 @@ describe('useNavigator - 사용자가 링크를 클릭했을 때', () => { ['vbscript', 'vbscript:msgbox("XSS")'], ['대문자 javascript', 'JAVASCRIPT:alert("XSS")'], ])('%s 프로토콜 링크는 차단된다', (_, maliciousUrl) => { - // When handleLink.result.current(maliciousUrl); - // Then expect(mockNavigate).not.toHaveBeenCalled(); expect(window.location.href).toBe(''); }); }); - describe('외부 사이트 링크를 클릭하면', () => { - it.each([ - ['https', 'https://example.com'], - ['http', 'http://example.com'], - ['App Store (itms-apps)', 'itms-apps://itunes.apple.com/app/123456'], - ])('%s 링크는 해당 사이트로 이동한다', (_, externalUrl) => { - // When - handleLink.result.current(externalUrl); - - // Then - expect(window.location.href).toBe(externalUrl); - expect(mockNavigate).not.toHaveBeenCalled(); + describe('일반 웹에서', () => { + describe('외부 링크를 클릭하면', () => { + it.each([ + ['https', 'https://example.com'], + ['http', 'http://example.com'], + ['itms-apps', 'itms-apps://itunes.apple.com/app/123456'], + ])('%s 링크는 window.location.href로 이동한다', (_, externalUrl) => { + handleLink.result.current(externalUrl); + + expect(window.location.href).toBe(externalUrl); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + describe('내부 경로를 클릭하면', () => { + it('React Router로 이동한다', () => { + handleLink.result.current('/introduce'); + + expect(mockNavigate).toHaveBeenCalledWith('/introduce'); + expect(window.location.href).toBe(''); + }); + + it('상대 경로도 React Router로 이동한다', () => { + handleLink.result.current('about'); + + expect(mockNavigate).toHaveBeenCalledWith('about'); + expect(window.location.href).toBe(''); + }); }); }); - describe('내부 페이지 링크를 클릭하면', () => { - it('소개 페이지로 이동할 수 있다', () => { - // When - handleLink.result.current('/introduce'); + describe('웹뷰에서', () => { + beforeEach(() => { + mockIsInAppWebView.mockReturnValue(true); + handleLink = renderHook(() => useNavigator()); + }); - // Then - expect(mockNavigate).toHaveBeenCalledWith('/introduce'); - expect(window.location.href).toBe(''); + describe('외부 링크를 클릭하면', () => { + it('http/https 링크는 requestOpenExternalUrl로 앱에 위임한다', () => { + mockRequestOpenExternalUrl.mockReturnValue(true); + + handleLink.result.current('https://example.com'); + + expect(mockRequestOpenExternalUrl).toHaveBeenCalledWith( + 'https://example.com', + ); + expect(window.location.href).toBe(''); + }); + + it('itms-apps:// 링크는 requestOpenExternalUrl이 false를 반환하면 window.open으로 폴백한다', () => { + mockRequestOpenExternalUrl.mockReturnValue(false); + + handleLink.result.current('itms-apps://itunes.apple.com/app/123456'); + + expect(mockRequestOpenExternalUrl).toHaveBeenCalled(); + expect(window.open).toHaveBeenCalledWith( + 'itms-apps://itunes.apple.com/app/123456', + ); + }); }); - it('상대 경로로도 이동할 수 있다', () => { - // When - handleLink.result.current('about'); + describe('내부 경로를 클릭하면', () => { + it('requestNavigateWebview로 앱에 위임한다', () => { + handleLink.result.current('/promotions/123'); - // Then - expect(mockNavigate).toHaveBeenCalledWith('about'); - expect(window.location.href).toBe(''); + expect(mockRequestNavigateWebview).toHaveBeenCalledWith( + 'promotions/123', + ); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('leading slash를 제거한 slug로 전달한다', () => { + handleLink.result.current('/festival-introduction'); + + expect(mockRequestNavigateWebview).toHaveBeenCalledWith( + 'festival-introduction', + ); + }); }); }); }); diff --git a/frontend/src/hooks/useNavigator.ts b/frontend/src/hooks/useNavigator.ts index 342f7c47c..6488244e8 100644 --- a/frontend/src/hooks/useNavigator.ts +++ b/frontend/src/hooks/useNavigator.ts @@ -1,5 +1,12 @@ import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; +import isInAppWebView from '@/utils/isInAppWebView'; +import { + requestNavigateWebview, + requestOpenExternalUrl, +} from '@/utils/webviewBridge'; + +const toSlug = (path: string) => path.replace(/^\//, ''); const useNavigator = () => { const navigate = useNavigate(); @@ -8,19 +15,20 @@ const useNavigator = () => { (url: string) => { const trimmedUrl = url?.trim(); if (!trimmedUrl) return; + if (/^(javascript|data|vbscript):/i.test(trimmedUrl)) return; - const isDangerousProtocol = /^(javascript|data|vbscript):/i.test( - trimmedUrl, - ); - if (isDangerousProtocol) return; - - const isExternalUrl = /^(https?|itms-apps):\/\//.test(trimmedUrl); + const inWebview = isInAppWebView(); + const isExternal = /^(https?|itms-apps):\/\//.test(trimmedUrl); - if (isExternalUrl) { - window.location.href = trimmedUrl; - } else { - navigate(trimmedUrl); + if (isExternal) { + if (inWebview && !requestOpenExternalUrl(trimmedUrl)) + window.open(trimmedUrl); + else if (!inWebview) window.location.href = trimmedUrl; + return; } + + if (inWebview) requestNavigateWebview(toSlug(trimmedUrl)); + else navigate(trimmedUrl); }, [navigate], ); diff --git a/frontend/src/hooks/useWebviewSubscribe.ts b/frontend/src/hooks/useWebviewSubscribe.ts new file mode 100644 index 000000000..7e6d2de69 --- /dev/null +++ b/frontend/src/hooks/useWebviewSubscribe.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useState } from 'react'; +import { USER_EVENT } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import { + AppToWebMessage, + requestSubscribeState, + requestSubscribeToggle, +} from '@/utils/webviewBridge'; + +const useWebviewSubscribe = () => { + const trackEvent = useMixpanelTrack(); + const [subscribedClubIds, setSubscribedClubIds] = useState>( + new Set(), + ); + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + let data: AppToWebMessage; + try { + data = + typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + } catch { + return; + } + + if (data.type === 'SUBSCRIBE_STATE') { + const incoming = data.payload.subscribedClubIds; + setSubscribedClubIds((prev) => { + if ( + prev.size === incoming.length && + incoming.every((id) => prev.has(id)) + ) + return prev; + return new Set(incoming); + }); + } else if (data.type === 'SUBSCRIBE_RESULT') { + const { clubId, subscribed } = data.payload; + setSubscribedClubIds((prev) => { + const alreadyCorrect = subscribed + ? prev.has(clubId) + : !prev.has(clubId); + if (alreadyCorrect) return prev; + const next = new Set(prev); + if (subscribed) next.add(clubId); + else next.delete(clubId); + return next; + }); + } + }; + + window.addEventListener('message', handleMessage); + requestSubscribeState(); + + return () => { + window.removeEventListener('message', handleMessage); + }; + }, []); + + const toggleSubscribe = useCallback( + (clubId: string, subscribed: boolean) => { + requestSubscribeToggle(clubId); + trackEvent(USER_EVENT.WEBVIEW_SUBSCRIBE_TOGGLED, { + club_id: clubId, + subscribed: !subscribed, + }); + }, + [trackEvent], + ); + + return { subscribedClubIds, toggleSubscribe }; +}; + +export default useWebviewSubscribe; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 147899748..e7cba9c40 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -7,6 +7,12 @@ initializeMixpanel(); initializeSentry(); initializeExperiments(); +if (import.meta.env.DEV) { + window.navermap_authFailure = function () { + console.error('Naver Map Error 인증 실패'); + }; +} + async function startApp() { if (import.meta.env.DEV) { const { worker } = await import('./mocks/browser'); diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts index fc0cb3248..13d45fc4b 100644 --- a/frontend/src/mocks/handlers/index.ts +++ b/frontend/src/mocks/handlers/index.ts @@ -1,7 +1,4 @@ import { promotionHandlers } from './promotion'; // 모든 MSW 핸들러를 여기에 통합 -export const handlers = [ - ...promotionHandlers, - // 다른 핸들러들을 여기에 추가 -]; +export const handlers = [...promotionHandlers]; diff --git a/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx b/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx index f853e74b5..9d6205453 100644 --- a/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx +++ b/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { allowPersonalInformation } from '@/apis/auth'; import Button from '@/components/common/Button/Button'; -import PortalModal from '@/components/common/Modal/PortalModal'; +import Modal from '@/components/common/Modal/Modal'; import { STORAGE_KEYS } from '@/constants/storageKeys'; import { useAdminClubContext } from '@/context/AdminClubContext'; import * as Styled from './PersonalInfoConsentModal.styles'; @@ -44,7 +44,7 @@ const PersonalInfoConsentModal = ({ }; return ( - {}} closeOnBackdrop={false}> + {}} closeOnBackdrop={false}> {clubName}님, 환영합니다! @@ -65,7 +65,7 @@ const PersonalInfoConsentModal = ({ {loading ? '처리 중...' : '확인하고 시작하기'} - + ); }; diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.styles.ts b/frontend/src/pages/ClubDetailPage/ClubDetailPage.styles.ts index aa5346f9a..24e70a26c 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.styles.ts +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.styles.ts @@ -1,5 +1,6 @@ import styled from 'styled-components'; import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; import { transitions } from '@/styles/theme/transitions'; export const Container = styled.div` @@ -32,8 +33,70 @@ export const ContentWrapper = styled.div` } `; +export const LeftSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + flex-shrink: 0; + width: 400px; + + ${media.tablet} { + width: 100%; + gap: 0px; + } +`; + +export const MapInfo = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + + ${media.tablet} { + display: none; + } +`; + +export const MapCard = styled.div` + width: 100%; + height: 189px; + + border-radius: 20px; + border: 1px solid ${colors.gray[400]}; + overflow: hidden; + cursor: pointer; + + background-color: #f2f2f2; + + * { + cursor: pointer !important; + } +`; + +export const MapDetailText = styled.div` + display: flex; + align-items: center; + gap: 2px; + + padding: 0 2px; + + font-size: 14px; + color: ${colors.gray[700]}; + cursor: default; + user-select: none; + + img { + width: 12px; + height: 15px; + } +`; + export const RightSection = styled.div` width: 100%; + + ${media.tablet} { + border-top: 6px solid ${colors.gray[200]}; + padding-top: 12px; + } `; export const TabContent = styled.div` diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index 85d617166..a4f3ffaa3 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -1,8 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; +import locationIcon from '@/assets/images/icons/location_icon.svg'; import Footer from '@/components/common/Footer/Footer'; import Header from '@/components/common/Header/Header'; import UnderlineTabs from '@/components/common/UnderlineTabs/UnderlineTabs'; +import MapModal from '@/components/map/MapModal/MapModal'; +import NaverMap from '@/components/map/NaverMap/NaverMap'; +import { clubLocations } from '@/constants/clubLocation'; import { PAGE_VIEW, USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; @@ -55,21 +59,8 @@ const ClubDetailPage = () => { if (!tabParam || !Object.values(TAB_TYPE).includes(tabParam)) { return TAB_TYPE.INTRO; } - if (tabParam === TAB_TYPE.SCHEDULE && !hasCalendarConnection) { - return TAB_TYPE.INTRO; - } return tabParam; - }, [tabParam, hasCalendarConnection]); - - useEffect(() => { - if ( - clubDetail && - tabParam === TAB_TYPE.SCHEDULE && - !hasCalendarConnection - ) { - setSearchParams({ tab: TAB_TYPE.INTRO }, { replace: true }); - } - }, [clubDetail, tabParam, hasCalendarConnection, setSearchParams]); + }, [tabParam]); const { data: calendarEvents = [] } = useGetClubCalendarEvents( (clubName ?? clubId) || '', @@ -77,27 +68,21 @@ const ClubDetailPage = () => { ); const tabs = useMemo( - () => - [ - { key: TAB_TYPE.INTRO, label: '소개 내용' }, - { key: TAB_TYPE.PHOTOS, label: '활동사진' }, - hasCalendarConnection - ? { key: TAB_TYPE.SCHEDULE, label: '일정 보기' } - : null, - ].filter(Boolean) as Array<{ key: TabType; label: string }>, - [hasCalendarConnection], + () => [ + { key: TAB_TYPE.INTRO, label: '소개내용' }, + { key: TAB_TYPE.PHOTOS, label: '활동사진' }, + { key: TAB_TYPE.SCHEDULE, label: '행사일정' }, + ], + [], ); const topBarTabs = useMemo( - () => - [ - { key: TAB_TYPE.INTRO, label: '소개내용' }, - { key: TAB_TYPE.PHOTOS, label: '활동사진' }, - hasCalendarConnection - ? { key: TAB_TYPE.SCHEDULE, label: '일정 보기' } - : null, - ].filter(Boolean) as Array<{ key: TabType; label: string }>, - [hasCalendarConnection], + () => [ + { key: TAB_TYPE.INTRO, label: '소개내용' }, + { key: TAB_TYPE.PHOTOS, label: '활동사진' }, + { key: TAB_TYPE.SCHEDULE, label: '행사일정' }, + ], + [], ); useTrackPageView( @@ -128,6 +113,12 @@ const ClubDetailPage = () => { [setSearchParams, trackEvent], ); + const clubLocation = clubLocations.find( + (loc) => loc.clubName === clubDetail?.name, + ); + + const [isMapModalOpen, setIsMapModalOpen] = useState(false); + if (error) { return
동아리 정보를 불러오는데 실패했습니다.
; } @@ -154,14 +145,31 @@ const ClubDetailPage = () => { )} - + + setIsMapModalOpen(true)} + /> + {clubLocation && ( + + setIsMapModalOpen(true)}> + + + + + 위치 아이콘 + 동아리방 위치 {clubLocation.building}{' '} + {clubLocation.detailLocation} + + + )} + { > - {hasCalendarConnection && ( -
- -
- )} +
+ +
+ {clubLocation && ( + setIsMapModalOpen(false)} + clubName={clubDetail.name} + clubLogo={clubDetail.logo} + location={clubLocation} + /> + )} {!isInAppWebView() &&