diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml new file mode 100644 index 000000000..f4546b980 --- /dev/null +++ b/.github/workflows/pr-auto-label.yml @@ -0,0 +1,75 @@ +name: PR Auto Label + +on: + pull_request: + types: [opened, edited, synchronize] + branches: [main, develop-fe, develop/be] + +jobs: + label: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Auto Label PR + uses: actions/github-script@v7 + with: + script: | + const title = context.payload.pull_request.title.toLowerCase(); + const prNumber = context.payload.pull_request.number; + + // 1. PR 제목 기반 타입 라벨 (conventional commit) + const typeMap = [ + { pattern: /^feat/, label: '✨ Feature' }, + { pattern: /^hotfix/, label: '🚀 hotfix' }, + { pattern: /^fix/, label: '🛠Fix' }, + { pattern: /^bug/, label: '🐞 Bug' }, + { pattern: /^refactor/, label: '🔨 Refactor' }, + { pattern: /^docs/, label: '📃 Docs' }, + { pattern: /^chore/, label: '🚗 Chore' }, + { pattern: /^style/, label: '🎨 Design' }, + { pattern: /^test/, label: '✅ Test' }, + { pattern: /^ci|^cd/, label: '📦 CI/CD' }, + { pattern: /^deploy|^release/, label: '🌏 Deploy' }, + { pattern: /^setup|^setting/, label: '⚙ Setting' }, + { pattern: /^api/, label: '📬 API' }, + ]; + + const labelsToAdd = []; + + for (const { pattern, label } of typeMap) { + if (pattern.test(title)) { + labelsToAdd.push(label); + break; + } + } + + // AB TEST + if (/experiment|ab.?test/i.test(title)) labelsToAdd.push('AB TEST'); + + // AI 활용 감지 (제목 또는 본문) + const body = context.payload.pull_request.body ?? ''; + if (/claude|codex/i.test(title + body)) labelsToAdd.push('🚁AI'); + + // 2. 변경 파일 경로 기반 FE/BE 라벨 + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + const filenames = files.map(f => f.filename); + if (filenames.some(f => f.startsWith('frontend/'))) labelsToAdd.push('💻 FE'); + if (filenames.some(f => f.startsWith('backend/'))) labelsToAdd.push('💾 BE'); + + if (labelsToAdd.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: labelsToAdd, + }); + core.info(`Added labels: ${labelsToAdd.join(', ')}`); + } diff --git "a/frontend/.claude/agents/RN\354\227\220\354\235\264\354\240\204\355\212\270.md" "b/frontend/.claude/agents/RN\354\227\220\354\235\264\354\240\204\355\212\270.md" new file mode 100644 index 000000000..fc8586bd1 --- /dev/null +++ "b/frontend/.claude/agents/RN\354\227\220\354\235\264\354\240\204\355\212\270.md" @@ -0,0 +1,121 @@ +# RN 에이전트 (React Native) + +모아동 React Native + Expo 프로젝트 전담 에이전트. +WebView 브릿지, 딥링크/라우팅, Push 알림, API/인증 관련 코드를 분석하고 수정한다. + +## 프로젝트 경로 + +/Users/seokyoung-won/Desktop/moadong-react-native/ + +## 기술 스택 + +- Expo SDK + Expo Router (파일 기반 라우팅) +- React Native New Architecture (`newArchEnabled: true`) +- React Compiler (실험적 기능) +- Axios (`publicApi` / `authApi`) — `api` default export는 deprecated +- React Context (Redux/Zustand 미사용) +- Firebase (Remote Config, FCM) +- Mixpanel 애널리틱스 + +## 아키텍처 + +### 라우팅 구조 + +``` +app/ + _layout.tsx # 루트 레이아웃: 부트스트랩, 스플래시, 강제 업데이트, Context + (tabs)/ + index.tsx # 홈 탭 + explore.tsx # 탐색 탭 + more.tsx # 더보기 탭 + club/[id].tsx # 동아리 상세 (WebView) + clubDetail/[id].tsx # 동아리 상세 (네이티브) + webview/[slug].tsx # 범용 WebView 화면 + modal.tsx # 모달 +``` + +### UI 레이어 패턴 + +기능별 폴더 `ui//` 하위 구조 (필요한 것만 생성): + +- `hook/` — 데이터 페칭 훅 (예: `useClubs`, `useSubscribedClubs`) +- `model/` — 파생 상태 / 데이터 변환 (선택) +- `components/` — UI 컴포넌트 +- `index.ts` — barrel export + +### 부트스트랩 순서 (`app/_layout.tsx`) + +1. Firebase Remote Config 강제 업데이트 체크 +2. iOS ATT 권한 요청 +3. 액세스 토큰 조회/생성 +4. FCM 토큰 등록 +5. 서버에서 구독 동아리 목록 동기화 +6. Mixpanel 초기화 + +### API 클라이언트 (`services/api.ts`) + +- `publicApi` — 인증 없는 요청 +- `authApi` — Bearer 토큰 자동 첨부, 401 시 `/auth/student`로 자동 갱신 +- ⚠️ `api` (default export) 는 deprecated — 신규 코드에 사용 금지 + +### 상태 관리 (Context) + +- `SubscribedClubsProvider` (`contexts/subscribed-clubs-context.tsx`) — 구독 동아리 목록, 토글, 서버 동기화 +- `MixpanelProvider` (`contexts/mixpanel-context.tsx`) — 애널리틱스 + +## 시나리오별 핵심 파일 + +### WebView 브릿지 + +웹 ↔ RN 메시지 통신 관련 파일: + +- `app/club/[id].tsx` — WebView 컴포넌트, `onMessage` 핸들러 +- `app/webview/[slug].tsx` — 범용 WebView, postMessage 처리 +- 프론트엔드 쪽: `src/utils/webviewBridge.ts` (참조용, 수정 불가) + +### 딥링크 / 라우팅 + +- `app/_layout.tsx` — Linking 설정, 초기 라우트 처리 +- `app.json` — 딥링크 스킴(`moadongapp://`), associated domain(`www.moadong.com`) +- Expo Router 파일 기반 라우팅: `app/` 디렉토리에 파일 추가 = 라우트 자동 등록 + +### Push 알림 + +- `app/_layout.tsx` 4번째 부트스트랩 단계에서 FCM 토큰 등록 +- `services/api.ts` — 토큰 등록 API 호출 +- `google-services.json` / `GoogleService-Info.plist` — Firebase 설정 파일 + +### API / 인증 + +- `services/api.ts` — Axios 인스턴스 정의 +- `contexts/subscribed-clubs-context.tsx` — `authApi` 사용 예시 +- 환경 변수: `EXPO_PUBLIC_BASE_URL` (`.env` 파일) + +## 디자인 시스템 (`constants/theme.ts`) + +- `MainColors` — 오렌지 계열 (`main` = `#FF5414`) +- `TagColors` — 카테고리별 색상 (봉사/학술/종교/취미교양/운동/공연) +- `Spacing` — 4px 기준: `xs`(4) `sm`(8) `md`(16) `lg`(24) `xl`(32) `xxl`(40) `xxxl`(48) +- `BorderRadius` — `xs`(4) `sm`(8) `md`(12) `lg`(16) `xl`(20) `full`(9999) +- 폰트: Pretendard. RN `Text` 대신 `@/components/moa-text`의 `` 사용 + +## 네이밍 컨벤션 + +- 파일명: `kebab-case.tsx` +- 컴포넌트: `PascalCase` +- 훅: `use` 접두사 + `camelCase` +- **named export 선호** — default export는 `app/` 화면 컴포넌트에만 허용 +- 경로 별칭: `@/` → 프로젝트 루트 +- 플랫폼별 파일: `.ios.tsx` / `.web.ts` 접미사 + +## 수정 체크리스트 + +코드 수정 시 반드시 확인: + +- [ ] `authApi` / `publicApi` 구분 올바른가? (`api` default 사용 금지) +- [ ] 파일명이 `kebab-case`인가? +- [ ] named export를 사용했는가? (`app/` 화면 제외) +- [ ] `@/` 경로 별칭을 사용했는가? (상대 경로 지양) +- [ ] TypeScript 타입이 명시적인가? (`any` 금지) +- [ ] 디자인 토큰을 `constants/theme.ts`에서 가져왔는가? +- [ ] 텍스트에 RN `` 대신 `` (`@/components/moa-text`)를 사용했는가? diff --git a/frontend/.claude/commands/RN.md b/frontend/.claude/commands/RN.md new file mode 100644 index 000000000..8fe8135a5 --- /dev/null +++ b/frontend/.claude/commands/RN.md @@ -0,0 +1,22 @@ +--- +description: React Native 프로젝트 분석 및 수정을 RN 에이전트에게 위임 +allowed-tools: Agent +--- + +RN(React Native) 프로젝트 작업을 RN에이전트 서브에이전트에게 위임합니다. + +## 작업 내용 + +$ARGUMENTS + +## 지시사항 + +`$ARGUMENTS`가 비어 있으면, 사용자에게 어떤 RN 관련 작업이 필요한지 물어본 뒤 RN에이전트를 호출하세요. + +Agent 툴로 `RN에이전트` 서브에이전트를 호출하여 위 작업을 처리하세요. + +다음 컨텍스트를 에이전트에게 전달하세요: + +- RN 프로젝트 경로: `/Users/seokyoung-won/Desktop/moadong-react-native/` +- 현재 프론트엔드 프로젝트 경로: `/Users/seokyoung-won/Desktop/moadong/frontend/` +- 두 프로젝트가 WebView로 연동되어 있음 (프론트엔드 웹 → RN WebView) diff --git a/frontend/.claude/commands/check-tracking.md b/frontend/.claude/commands/check-tracking.md new file mode 100644 index 000000000..30e591350 --- /dev/null +++ b/frontend/.claude/commands/check-tracking.md @@ -0,0 +1,90 @@ +# 이벤트 트래킹 감사 (Event Tracking Audit) + +`src/constants/eventName.ts`의 정의와 실제 코드베이스 사용 현황을 비교해 Mixpanel 트래킹 누락을 점검합니다. + +> **실행 위치**: 아래 모든 명령은 **`frontend/` 디렉터리**에서 실행해야 합니다. +> 모노레포 루트에서 실행하면 `src/` 경로를 찾지 못해 결과가 0건으로 나옵니다. + +## 분석 순서 + +아래 bash 명령을 순서대로 실행하고 결과를 수집하세요. + +### Step 1: 정의된 이벤트 목록 추출 + +```bash +grep -E "^\s+[A-Z_]+:" src/constants/eventName.ts +``` + +`USER_EVENT` 안의 키만 수집합니다 (`ADMIN_EVENT`, `PAGE_VIEW`는 제외). + +### Step 2: 실제 trackEvent 호출 현황 + +```bash +grep -rn "trackEvent(USER_EVENT\." src/ --include="*.tsx" --include="*.ts" +``` + +어떤 파일의 몇 번째 줄에서 어떤 이벤트를 호출하는지 수집합니다. + +### Step 3: 트래킹 미적용 인터랙션 탐지 + +```bash +grep -rn "onClick\|onSubmit" src/pages/ src/components/ --include="*.tsx" -l +``` + +위 파일 목록 중 `trackEvent`를 import하는 파일: + +```bash +grep -rln "trackEvent\|useMixpanelTrack" src/pages/ src/components/ --include="*.tsx" +``` + +두 목록의 **교집합 파일** — 즉, onClick/onSubmit이 있고 trackEvent도 쓰는 파일 — 에서 trackEvent 없이 onClick만 있는 핸들러를 탐지합니다: + +```bash +grep -n "onClick\|onSubmit" <파일경로> +``` + +각 핸들러 주변 5줄을 읽어 trackEvent 호출이 없으면 누락 의심으로 분류합니다. + +### Step 4: 미사용 이벤트 탐지 + +Step 1에서 수집한 USER_EVENT 키 각각에 대해 Step 2 결과에서 사용 여부를 확인합니다. +한 번도 등장하지 않는 키는 미사용 이벤트입니다. + +--- + +## 출력 형식 + +분석 결과를 아래 3개 표로 요약합니다. + +### ✅ 트래킹 중인 이벤트 + +| 이벤트명 | 사용 파일 | +| ------------------ | -------------------- | +| `CLUB_MAP_CLICKED` | `ClubDetailPage.tsx` | +| … | … | + +### ⚠️ 트래킹 누락 의심 인터랙션 + +onClick/onSubmit이 있으나 trackEvent 호출이 없는 핸들러. **사용자 의도가 담긴 주요 액션**(버튼 클릭, 폼 제출 등)에 한해 나열하고, 단순 상태 토글이나 내부 UI 제어는 제외합니다. + +| 파일 | 핸들러 | 비고 | +| ------------------------ | ------------ | -------------- | +| `ClubDetailPage.tsx:157` | `onMapClick` | 지도 모달 열기 | +| … | … | … | + +### 🗑️ 정의만 되고 미사용 이벤트 (USER_EVENT 기준) + +코드 어디서도 `trackEvent`로 호출되지 않는 이벤트. + +| 키 | 이벤트명 | +| ------------ | -------------- | +| `SOME_EVENT` | `'Some Event'` | +| … | … | + +--- + +## 주의사항 + +- `ADMIN_EVENT`와 `PAGE_VIEW`는 별도 트래킹 체계(useTrackPageView 등)를 사용하므로 이번 감사 대상에서 제외합니다. +- 누락 의심은 **사용자 행동 추적 가치가 있는** 인터랙션에 집중합니다. 드롭다운 토글, 입력 포커스 등 세부 UI 이벤트는 판단이 필요한 경우 별도로 언급합니다. +- 결과 출력 후 실제 추가가 필요한 항목이 있으면 이어서 작업할지 물어봅니다. diff --git a/frontend/.claude/commands/cross-review.md b/frontend/.claude/commands/cross-review.md new file mode 100644 index 000000000..b9e90f469 --- /dev/null +++ b/frontend/.claude/commands/cross-review.md @@ -0,0 +1,13 @@ +크로스 에이전트 코드 리뷰를 실행합니다. 코드를 작성한 AI와 다른 AI가 리뷰합니다. + +사용법: + +- `/cross-review claude` — Claude로 작성 → Codex가 리뷰 +- `/cross-review codex` — Codex로 작성 → Claude가 리뷰 +- `/cross-review` — 기본값: claude로 간주 (Codex가 리뷰) + +아래 명령을 실행하고 결과를 그대로 출력합니다: + +```bash +./scripts/cross-review.sh --writer ${ARGUMENTS:-claude} +``` diff --git a/frontend/.claude/commands/jira-story.md b/frontend/.claude/commands/jira-story.md new file mode 100644 index 000000000..3b8a7194d --- /dev/null +++ b/frontend/.claude/commands/jira-story.md @@ -0,0 +1,44 @@ +Jira 스토리를 대화형으로 생성합니다. + +다음 순서로 사용자에게 질문하세요: + +1. **제목**: "스토리 제목을 입력해주세요. (예: 사용자는 동아리 카드를 클릭하여 상세 페이지로 이동할 수 있다)" +2. **설명**: "스토리 설명을 입력해주세요. (없으면 Enter)" — 선택사항 +3. **인수 조건**: "인수 조건(Acceptance Criteria)을 입력해주세요. (없으면 Enter)" — 선택사항 + +모든 입력이 완료되면 아래 명령을 실행하세요: + +```bash +./scripts/jira-story.sh "제목" "설명" "인수조건" +``` + +- 설명이나 인수 조건이 없으면 빈 문자열("")로 전달합니다. +- 실행 후 생성된 스토리 키와 링크를 그대로 출력합니다. +- `JIRA_EMAIL` 또는 `JIRA_API_TOKEN` 환경 변수가 없으면 설정 방법을 안내합니다. + +--- + +## GitHub 이슈 (하위 작업 + 브랜치) 연동 + +스토리 생성 후 바로 이어서 물어보세요: + +"GitHub 이슈(Jira 하위 작업 + 브랜치)도 함께 생성할까요?" + +**Yes인 경우** 아래 순서로 추가 질문: + +4. **담당자**: "담당자를 선택해주세요." — seongwon030 / oesnuj / Zepelown / PororoAndFriends / lepitaaar / suhyun113 / alsdddk / yw6938 / seongje973 중 선택 +5. **브랜치명**: "브랜치명을 입력해주세요. (예: feature/add-login-page)" +6. **분기 브랜치**: "분기할 브랜치를 입력해주세요. (기본값: develop-fe)" — 선택사항 +7. **태스크**: "체크리스트를 입력해주세요. (없으면 Enter)" — 선택사항, 기본값 `- [ ] Task1` + +모든 입력이 완료되면: + +```bash +./scripts/jira-task.sh "제목" "담당자" "MOA-xxx" "브랜치명" "분기브랜치" "" "태스크" +``` + +- `MOA-xxx`는 앞서 생성된 스토리 키를 그대로 사용합니다. +- 분기 브랜치 미입력 시 `develop-fe` 사용합니다. +- 태스크 미입력 시 `- [ ] Task1` 기본값 사용합니다. +- 실행 후 GitHub 이슈 URL을 출력합니다. +- GitHub Actions이 자동으로 Jira 하위 작업과 브랜치를 생성합니다. diff --git a/frontend/.claude/commands/jira-task.md b/frontend/.claude/commands/jira-task.md new file mode 100644 index 000000000..ede85fec4 --- /dev/null +++ b/frontend/.claude/commands/jira-task.md @@ -0,0 +1,88 @@ +Jira 스토리 키를 기반으로 GitHub 이슈(하위 작업 + 브랜치)를 자동 생성합니다. + +## 흐름 + +1. **상위 스토리 키**: "상위 Jira 스토리 키를 입력해주세요. (예: MOA-874)" + +2. **분기 브랜치**: "분기할 브랜치를 선택해주세요. (기본값: develop-fe)" — `develop-fe` 또는 `develop/be` + +3. **마감일**: "마감일을 입력해주세요. (없으면 Enter → 오늘 날짜, 예: 2026-05-25)" — YYYY-MM-DD 형식, 미입력 시 오늘 날짜 자동 설정 + +4. 입력받은 키로 **Jira API를 호출**하여 스토리 정보를 자동 조회합니다: + +```bash +source .env 2>/dev/null +jq -n '{"jql":"key=<스토리키>","fields":["summary","description"]}' > /tmp/jql.json +curl -s -X POST -u "${JIRA_EMAIL}:${JIRA_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d @/tmp/jql.json \ + "https://${JIRA_HOST}/rest/api/3/search/jql" +``` + +5. 조회된 정보를 기반으로 **자동 생성**합니다: + - **제목**: Jira 스토리 제목과 동일하게 사용 + - **담당자**: git config user.name으로 자동 설정 + - **브랜치명**: 라벨에 맞는 접두사 + 제목의 핵심 키워드를 영문 kebab-case로 변환하여 `<접두사>/<설명>` 형식으로 생성. GitHub Actions가 이슈번호와 MOA 키를 자동으로 붙여줌 + - **태스크(체크리스트)**: 인수 조건(description에서 "✅ 인수 조건" 이후 텍스트)을 분석하여 `- [ ] 항목` 형식으로 자동 생성. 인수 조건이 없으면 `- [ ] Task1` 기본값 사용 + - **라벨**: 제목과 설명을 분석하여 가장 적절한 GitHub 라벨을 자동 선택 + +6. 자동 생성된 내용을 **사용자에게 보여주고 확인**받습니다: + +``` +📋 자동 생성 결과: + 제목: Jira 스토리 생성 스크립트 개선 (Windows 호환성, 자동 스프린트/담당자/에픽 지정) + 담당자: suhyun113 + 상위 스토리: MOA-874 + 브랜치명: refactor/jira-story-script-improve + 분기 브랜치: develop-fe + 라벨: 🔨 Refactor + 마감일: 2026-05-18 + 태스크: + - [ ] jq 미설치 시 OS별 안내 메시지 출력 + - [ ] Windows에서 JSON payload 정상 전달 + ... + +이대로 생성할까요? (수정할 항목이 있으면 알려주세요) +``` + +6. 확인 후 실행합니다: + +```bash +./scripts/jira-task.sh "제목" "담당자" "MOA-xxx" "브랜치명" "분기브랜치" "" "태스크" "라벨" "마감일" +``` + +## 규칙 + +- 제목은 항상 Jira 스토리 제목과 동일하게 사용합니다. +- 담당자는 `git config user.name`의 값을 사용합니다. 유효한 담당자 목록: seongwon030, oesnuj, Zepelown, PororoAndFriends, lepitaaar, suhyun113, alsdddk, yw6938, seongje973 +- 브랜치명은 `<접두사>/<설명>` 형식으로 생성합니다. GitHub Actions가 이슈번호(#xxx)와 Jira 키(MOA-xxx)를 자동으로 붙여줍니다. + - 접두사는 작업 성격 라벨에 따라 결정: + - `✨ Feature` → `feature/` + - `🛠Fix`, `🐞 Bug` → `fix/` + - `🔨 Refactor` → `refactor/` + - `🎨 Design` → `design/` + - `⚙ Setting`, `📦 CI/CD` → `chore/` + - `📃 Docs` → `docs/` + - `✅ Test` → `test/` + - `🚁AI` → `feature/` + - 설명은 제목의 핵심 키워드를 영문 kebab-case로 변환합니다. +- 태스크는 인수 조건의 각 항목을 `- [ ]` 체크리스트로 변환합니다. 인수 조건이 여러 문장이면 각각 별도 항목으로 분리합니다. +- 라벨은 **작업 성격 라벨 + 영역 라벨** 조합으로 지정합니다. `--label` 플래그를 여러 번 사용하여 복수 라벨을 전달합니다. + - **영역 라벨** (분기 브랜치에 따라 자동 결정): + - `develop-fe` → `💻 FE` + - `develop/be` → `💾 BE` + - **작업 성격 라벨** (제목과 설명을 분석하여 선택, 기본값 `✨ Feature`): + - `✨ Feature` — 새로운 기능 개발 + - `🐞 Bug` — 버그 수정 + - `🛠Fix` — 기능이 의도대로 동작하지 않는 수정 + - `🔨 Refactor` — 코드 리팩토링, 구조 개선 + - `🎨 Design` — 마크업, 스타일링 + - `📬 API` — 서버 API 통신 작업 + - `⚙ Setting` — 개발 환경 세팅 + - `📃 Docs` — 문서 작성 및 수정 + - `✅ Test` — 테스트 관련 + - `📦 CI/CD` — CI/CD 관련 + - `🚁AI` — Claude, Codex 활용 +- 분기 브랜치는 사용자에게 직접 물어봅니다. 기본값은 `develop-fe`입니다. +- 실행 후 GitHub 이슈 URL을 출력합니다. +- GitHub Actions(common-jira-create.yml)이 자동으로 Jira 하위 작업과 브랜치를 생성합니다. diff --git a/frontend/.claude/commands/mixpanel-ab.md b/frontend/.claude/commands/mixpanel-ab.md new file mode 100644 index 000000000..7c591b21c --- /dev/null +++ b/frontend/.claude/commands/mixpanel-ab.md @@ -0,0 +1,287 @@ +--- +description: Mixpanel A/B 테스트 유의성 검정 가이드 +allowed-tools: Bash(mcp-cli *), Read, Write, Edit, Glob, Grep +--- + +# A/B 테스트 유의성 검정 가이드 + +A/B 테스트 결과를 분석할 때 통계적 오류를 피하기 위한 체크리스트입니다. + +**프로젝트 ID**: `3611536` (Moadong) / `3974708` (moa_test) + +--- + +## 모아동 실험 프레임워크 구조 + +Mixpanel의 자체 Experiments 기능을 **사용하지 않는다**. 자체 `ExperimentRepository`로 variant를 할당하고, `mixpanel.register()`로 Super Property에 등록한다. + +```ts +// ExperimentRepository.ts +mixpanel.register({ [experiment.key]: variant }); +// 예: { festival_timetable_nav_v1: 'tabs' } +``` + +Super Property로 등록되므로 이후 발생하는 **모든 이벤트에 자동으로 variant가 포함**된다. + +**Mixpanel 쿼리 시 주의:** + +- `$experiment_started` 이벤트는 존재하지 않음 +- variant 구분은 `[experiment.key]` 프로퍼티로 breakdown +- 예: `festival_timetable_nav_v1 = 'tabs'` vs `'arrows'` + +**실험 정의 위치:** `src/experiments/definitions.ts` +**할당 저장 위치:** `localStorage('moadong_experiments')` + +--- + +## 실험 시작 전 체크리스트 + +**1. 샘플 크기 사전 계산 (필수)** + +실험 시작 전 반드시 필요 샘플 수를 계산합니다. 계산 없이 시작하면 무의미한 결과가 나올 가능성이 높습니다. + +``` +⚠️ Mixpanel은 신뢰구간 90% / 99% 두 가지만 제공 (95%는 없음) + +선택 기준: +- 90% CI (α = 0.10): 탐색적 실험, 저위험 의사결정, 빠른 결론이 필요할 때 +- 99% CI (α = 0.01): 핵심 지표 변경, 고위험 의사결정, 오판 비용이 클 때 + +샘플 크기 계산 시 Mixpanel 기준에 맞게 α 설정: +- 계산 도구: https://www.evanmiller.org/ab-testing/sample-size.html +- 검정력 (Power) = 0.80 +- MDE (최소 감지 효과): 탐지하려는 최소 상대적 개선율 + +예시 (전환율 10% 기준, 상대적 +20% 감지 목표): +- 90% CI 기준 (α=0.10): 약 1,400명/그룹 +- 99% CI 기준 (α=0.01): 약 2,700명/그룹 +``` + +**모아동 규모에서의 현실적 계산 (DAU 500명, 50:50 split):** + +``` +그룹당 유입 = 500 / 2 = 250명/일 + +1,400명/그룹 필요 → 약 6일 +2,700명/그룹 필요 → 약 11일 + ++ 최소 실험 기간 2주 권장 (요일 패턴 2사이클) +→ 사실상 2주가 기본 단위 +``` + +**2. Primary Metric 하나만 사전 지정** + +``` +❌ 잘못된 방법: 전환율, 클릭률, 체류시간 등 동시 분석 후 유의한 것만 선택 +✅ 올바른 방법: 실험 시작 전 Primary Metric 하나를 의사결정 기준으로 선언 + 나머지는 Secondary (참고용)로만 사용 +``` + +**현재 수집 중인 트래킹 데이터로 가능한 Primary Metric 예시:** + +| Primary Metric | 이벤트 조합 | 측정 방법 | +| -------------- | ------------------------------------------------------ | ------------------- | +| 카드 CTR | `ClubCard Viewed` → `ClubCard Clicked` | `club_id` 기준 비율 | +| 스크롤 도달률 | `Scroll Depth Reached` (depth=50%) | unique users 비율 | +| 상세 전환율 | `ClubCard Clicked` → `ClubDetailPage Visited` | 퍼널 전환율 | +| 지원 클릭률 | `ClubDetailPage Visited` → `Club Apply Button Clicked` | 퍼널 전환율 | + +**3. SRM (Sample Ratio Mismatch) 확인** + +실험 그룹 간 실제 트래픽 비율이 설계(50:50)와 크게 다르면 실험 설계 버그 신호입니다. + +모아동은 Super Property 기반이므로 **실험 키 프로퍼티로 breakdown**해서 확인합니다. + +```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": "SRM 확인 - variant 별 유저 수", + "dateRange": { "type": "relative", "range": { "unit": "day", "value": 14 } }, + "metrics": [ + { "eventName": "MainPage Visited", "measurement": { "type": "basic", "math": "unique" } } + ], + "breakdowns": [ + { + "metric": { + "type": "property", + "propertyName": "festival_timetable_nav_v1", + "propertyType": "string", + "resource": "event" + } + } + ], + "chartType": "table" + } +} +EOF +# A군 vs B군 유저 수 차이가 10% 이상 → 원인 파악 후 실험 재설계 +# 프로퍼티명은 experiment.key 값으로 교체 +``` + +--- + +## 실험 진행 중 체크리스트 + +**4. 피킹(Peeking) 금지 — 가장 흔한 오류** + +``` +❌ 잘못된 방법: 매일 결과를 확인하다가 유의해지는 순간 실험 중단 + → 실제 1종 오류율이 최대 26%까지 상승 + +✅ 올바른 방법: 사전에 정한 기간 또는 샘플 크기에 도달한 후에만 결론 +``` + +**5. 최소 실험 기간 준수** + +``` +권장: 최소 2주 이상 (요일별 패턴이 2사이클 포함되어야 함) +이유: 주중/주말 행동 패턴 차이가 결과를 왜곡할 수 있음 + +노벨티 효과 (Novelty Effect): +- 실험 초기 1~3일은 새로운 UI에 대한 일시적 반응일 수 있음 +- 방법: Mixpanel Insights에서 날짜 범위를 실험 시작 4일 이후부터 설정 +``` + +--- + +## 결과 해석 체크리스트 + +**6. 다중 비교 보정 (Multiple Comparisons)** + +``` +여러 지표를 동시에 검정할 경우 Bonferroni 보정 적용: +보정된 유의 수준 = α / 검정 지표 수 + +예 (90% CI 기준, α=0.10): +- 지표 5개 동시 검정 → 보정 α = 0.02 +- 지표 10개 동시 검정 → 보정 α = 0.01 +``` + +**Mixpanel에서 Bonferroni 보정 적용 방법:** + +Mixpanel UI는 90%/99%만 선택 가능하므로 보정된 α를 직접 설정할 수 없습니다. +외부 도구로 p-value를 계산한 뒤 보정된 기준으로 판단합니다. + +``` +1. Mixpanel에서 각 지표의 유저 수와 전환율 추출 +2. https://www.evanmiller.org/ab-testing/chi-squared.html 에서 p-value 계산 +3. 보정된 α(예: 0.02)와 비교해서 유의 여부 판단 + +Secondary Metric은 참고용이며, 단독으로 성공 선언 금지 +``` + +**7. 통계적 유의성 ≠ 실용적 유의성** + +``` +예시: +- 통계적으로 유의한 결과 +- 카드 CTR 변화: 10.0% → 10.1% (+0.1%p 절대 개선) +- DAU 500명 기준 → 하루 고작 0.5명 추가 클릭 + +→ p-value만 보지 말고 효과 크기(Effect Size)와 신뢰구간을 함께 확인 +``` + +**8. 신뢰구간 기반 해석** + +``` +Mixpanel 제공 CI 기준으로 결과 방향성 확인: + +✅ 명확한 개선: CI [+2%, +8%] → 0을 포함하지 않음, 개선 방향 확실 +⚠️ 불확실: CI [-1%, +11%] → 0을 포함, 방향 불명확 +❌ 효과 없음: CI [-3%, +2%] → 0 중심, 유의미한 효과 없음 +``` + +--- + +## Mixpanel 쿼리 패턴 + +### 카드 CTR 비교 (variant별) + +```bash +mcp-cli call claude_ai_mixpanel/Run-Query - <<'EOF' +{ + "project_id": 3611536, + "report_type": "funnels", + "report": { + "name": "A/B - 카드 노출 → 클릭 CTR", + "dateRange": { "type": "relative", "range": { "unit": "day", "value": 14 } }, + "steps": [ + { "eventName": "ClubCard Viewed" }, + { "eventName": "ClubCard Clicked" } + ], + "breakdowns": [ + { + "metric": { + "type": "property", + "propertyName": "festival_timetable_nav_v1", + "propertyType": "string", + "resource": "event" + } + } + ] + } +} +EOF +``` + +### 스크롤 도달률 비교 (variant별) + +```bash +mcp-cli call claude_ai_mixpanel/Run-Query - <<'EOF' +{ + "project_id": 3611536, + "report_type": "insights", + "report": { + "name": "A/B - 스크롤 50% 도달 유저 비율", + "dateRange": { "type": "relative", "range": { "unit": "day", "value": 14 } }, + "metrics": [ + { + "eventName": "Scroll Depth Reached", + "measurement": { "type": "basic", "math": "unique" }, + "filters": [ + { "propertyName": "depth_percent", "propertyType": "number", "value": 50, "operator": "equals" } + ] + } + ], + "breakdowns": [ + { + "metric": { + "type": "property", + "propertyName": "festival_timetable_nav_v1", + "propertyType": "string", + "resource": "event" + } + } + ], + "chartType": "table" + } +} +EOF +``` + +--- + +## 🚫 금지 행동 요약 + +| 행동 | 왜 문제인가 | 대안 | +| ------------------------------ | -------------------------- | ----------------------------------------- | +| 유의해지는 순간 중단 (피킹) | 1종 오류율 최대 26%로 폭발 | 사전 계획한 기간/샘플 수 준수 | +| 유의한 지표만 선택적 보고 | p-hacking, 결과 왜곡 | Primary Metric 사전 지정 | +| 작은 샘플로 트렌드 해석 | 노이즈를 신호로 오인 | 샘플 크기 충족 후 결론 | +| 서브그룹만 유의할 때 성공 선언 | 다중 비교 오류 | 전체 모집단 기준으로 판단 | +| 이전 실험과 데이터 합산 | 독립성 가정 위반 | 실험 기간 분리하여 분석 | +| SRM 확인 생략 | 버그를 효과로 착각 | 결론 전 반드시 SRM 체크 | +| `$experiment_started`로 쿼리 | 모아동은 해당 이벤트 없음 | experiment.key Super Property로 breakdown | + +--- + +## 참고 + +- 실험 프레임워크: `src/experiments/` +- 실험 정의: `src/experiments/definitions.ts` +- 이벤트명: `src/constants/eventName.ts` +- 일반 Mixpanel 분석: `/mixpanel` diff --git a/frontend/.claude/commands/mixpanel.md b/frontend/.claude/commands/mixpanel.md index 18811e69f..a41603e3f 100644 --- a/frontend/.claude/commands/mixpanel.md +++ b/frontend/.claude/commands/mixpanel.md @@ -428,6 +428,11 @@ EOF --- +> **A/B 테스트 유의성 검정**: `/mixpanel-ab` 커맨드를 사용하세요. +> 샘플 크기 계산, 피킹 방지, SRM 확인, 다중 비교 보정 등 실험 설계 체크리스트 포함. + +--- + ## Step 6: 문서화 ### 6-1. 리포트 파일 자동 생성 diff --git a/frontend/.claude/commands/prd.md b/frontend/.claude/commands/prd.md new file mode 100644 index 000000000..22e231b87 --- /dev/null +++ b/frontend/.claude/commands/prd.md @@ -0,0 +1,104 @@ +# PRD — 기능 분해 및 이슈 일괄 생성 + +기능 요구사항을 대화형으로 수집하고, 하위 작업으로 분해한 뒤 +Jira 스토리와 GitHub 이슈(하위 작업 + 브랜치)를 한 번에 생성합니다. + +**전체 흐름**: [인터뷰] → [하위 작업 분해 & 확인] → [스토리 생성] → [이슈 일괄 생성] + +--- + +## Phase 1: 기능 인터뷰 + +아래 순서로 **한 번에 하나씩** 질문하세요: + +1. **기능 제목**: "어떤 기능을 개발하나요? (예: 동아리 즐겨찾기 기능)" +2. **배경 및 목적**: "왜 필요한가요? 어떤 문제를 해결하나요? (없으면 Enter)" — 선택사항 +3. **주요 요구사항**: "핵심 요구사항을 설명해주세요. UI/기능/API 등 자유롭게 기술해주세요." +4. **담당자**: "담당자를 선택해주세요. (모든 하위 작업에 공통 적용)" + - 선택지: seongwon030 / oesnuj / Zepelown / PororoAndFriends / lepitaaar / suhyun113 / alsdddk / yw6938 / seongje973 +5. **분기 브랜치**: "분기할 브랜치를 입력해주세요. (기본값: develop-fe)" — 선택사항 + +--- + +## Phase 2: 하위 작업 분해 + +수집한 요구사항을 분석해 **3~6개의 하위 작업**으로 분해하세요. + +### 분해 기준 + +- 독립적으로 개발 가능하고 하나의 PR이 될 수 있는 크기 +- 역할/레이어 기준으로 나눔: UI 컴포넌트, API 연동, 상태관리, 스타일링, 테스트 등 +- 의존 관계가 있으면 번호 순서로 암묵적으로 표현 + +### 각 하위 작업에 포함할 항목 + +- **제목**: 간결하게 (예: "즐겨찾기 버튼 UI 구현") +- **브랜치명**: `feature/동사-명사-kebab-case` 형식, 영어만 사용 +- **태스크**: 이 하위 작업에서 해야 할 일 체크리스트 (1~3개, `- [ ] ...` 형식) + +### 출력 형식 + +분해 결과를 표로 보여주고 사용자 확인을 받으세요: + +``` +| # | 제목 | 브랜치명 | 태스크 | +|---|---|---|---| +| 1 | ... | feature/... | - [ ] ... | +| 2 | ... | feature/... | - [ ] ... | +``` + +"이 구성으로 진행할까요? 수정이 필요하면 말씀해주세요." + +- 수정 요청이 있으면 반영 후 표를 다시 출력하고 재확인 +- 확인 완료 후 Phase 3 진행 + +--- + +## Phase 3: 실행 + +### Step 1 — Jira 스토리 생성 + +```bash +./scripts/jira-story.sh "기능 제목" "배경 및 목적" "주요 요구사항" +``` + +- 설명/인수조건 없으면 빈 문자열("")로 전달 +- 출력에서 `MOA-xxx` 형식의 스토리 키를 반드시 추출해 Step 2에 사용 + +### Step 2 — GitHub 이슈 순차 생성 + +하위 작업 목록을 **순서대로 하나씩** 실행합니다 (병렬 실행 금지): + +```bash +./scripts/jira-task.sh "하위작업 제목" "담당자" "MOA-xxx" "브랜치명" "분기브랜치" "" "태스크 체크리스트" +``` + +- `MOA-xxx`는 Step 1에서 추출한 스토리 키 +- 태스크 체크리스트 여러 줄은 `\n`으로 연결: `"- [ ] Task1\n- [ ] Task2"` +- 각 실행 후 출력된 GitHub 이슈 URL을 수집 + +--- + +## Phase 4: 완료 출력 + +모든 이슈 생성 후 아래 형식으로 결과를 출력하세요: + +``` +✅ 스토리: MOA-xxx — https://...atlassian.net/browse/MOA-xxx + +📋 생성된 하위 작업 (총 N개): + 1. [하위작업 제목] → https://github.com/Moadong/moadong/issues/N + 2. [하위작업 제목] → https://github.com/Moadong/moadong/issues/N + ... + +⏳ GitHub Actions이 각 이슈마다 Jira 하위 작업과 브랜치를 자동 생성합니다. +``` + +--- + +## 주의사항 + +- 하위 작업이 6개를 초과하면 유사한 것들의 병합을 제안하세요 +- 태스크 문자열에 쌍따옴표(`"`)가 포함되면 bash 오류 발생 → 홑따옴표 또는 제거 +- `JIRA_EMAIL`, `JIRA_API_TOKEN` 환경 변수 없으면 `jira-story.sh` 실행 전 안내 +- `gh auth status`로 GitHub CLI 로그인 상태 확인 필요 diff --git a/frontend/.gitignore b/frontend/.gitignore index 0fb107ba4..a9672edd3 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -27,5 +27,14 @@ AGENTS.md # oh-my-claudecode .omc/ -# Claude Code (personal settings) -.claude/ +# Claude Code (personal settings — commands/ and agents/ are team-shared) +.claude/* +!.claude/commands/ +!.claude/commands/** +!.claude/agents/ +!.claude/agents/** + +.vercel + +# AI 작업 문서 +docs/superpowers/ diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index c4fbd322c..47b07091f 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -226,3 +226,22 @@ Agent 사용 시 해당 문서를 참조하여 일관된 패턴 유지. @docs/claude/testing.md @docs/claude/features.md @docs/claude/conventions.md + +## Skill routing + +When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill. + +Key routing rules: + +- Product ideas/brainstorming → invoke /office-hours +- Strategy/scope → invoke /plan-ceo-review +- Architecture → invoke /plan-eng-review +- Design system/plan review → invoke /design-consultation or /plan-design-review +- Full review pipeline → invoke /autoplan +- Bugs/errors → invoke /investigate +- QA/testing site behavior → invoke /qa or /qa-only +- Code review/diff check → invoke /review +- Visual polish → invoke /design-review +- Ship/deploy/PR → invoke /ship or /land-and-deploy +- Save progress → invoke /context-save +- Resume context → invoke /context-restore diff --git a/frontend/docs/claude/api.md b/frontend/docs/claude/api.md index 745dc37f5..9d81c0cfd 100644 --- a/frontend/docs/claude/api.md +++ b/frontend/docs/claude/api.md @@ -9,6 +9,25 @@ API는 `src/apis/utils/apiHelpers.ts`의 헬퍼 함수를 사용하는 일관된 쿼리 키는 `src/constants/queryKeys.ts`에 중앙 관리. +## React Query 캐싱 전략 (staleTime / gcTime) + +데이터 변경 빈도와 실시간성 요구에 따라 아래 기준으로 설정: + +| 데이터 성격 | staleTime | gcTime | 사용 예 | +| ---------------------------------- | ------------------------------------ | --------------------- | --------------------------------------- | +| 거의 변하지 않는 정적 데이터 | `60 * 60 * 1000` (1시간) | `60 * 60 * 1000` | `useBanner` | +| 자주 바뀌지 않는 일반 데이터 | `5 * 60 * 1000` (5분) | 기본값 (5분) | `useGoogleCalendar`, 클럽 캘린더 이벤트 | +| 일반 목록/상세 데이터 | `60 * 1000` (1분) | 기본값 | 클럽 목록, 클럽 상세 | +| 폴링 + stale 마커 | `60 * 1000` (1분) | 기본값 | `usePromotion` (refetchInterval 병행) | +| 사용자 입력에 반응하는 데이터 | `30 * 1000` (30초) | 기본값 | 클럽 검색, 자동완성 | +| 항상 최신값이 필요한 실시간 데이터 | `0` | 기본값 | `useGame` | + +**규칙:** + +- `staleTime`만 설정하는 경우 `gcTime`은 기본값(5분)으로 유지 +- 정적 데이터처럼 메모리에 오래 유지해야 하는 경우에만 `gcTime`을 `staleTime`과 함께 명시 +- 실시간성이 중요한 데이터는 `staleTime: 0` (기본값이므로 생략 가능하나 의도 명시를 위해 작성) + ## 인증 플로우 - JWT는 localStorage에 저장 (`accessToken` 키, `src/constants/storageKeys.ts`에서 관리) diff --git a/frontend/docs/claude/conventions.md b/frontend/docs/claude/conventions.md index f3cfb0acc..48fbba505 100644 --- a/frontend/docs/claude/conventions.md +++ b/frontend/docs/claude/conventions.md @@ -16,3 +16,9 @@ - styled-components 사용, 테마 시스템 활용 - `any` 금지, 명시적 타입 정의 - 상수는 `src/constants/`에서 관리 + +## Mixpanel 이벤트 트래킹 + +- 이벤트명은 `src/constants/eventName.ts`의 `USER_EVENT`에서 관리 +- 문자열 하드코딩 금지 +- sessionStorage 키는 `page + id` 스코프로 작성 diff --git a/frontend/docs/claude/features.md b/frontend/docs/claude/features.md index fd12813f9..2feb3be8b 100644 --- a/frontend/docs/claude/features.md +++ b/frontend/docs/claude/features.md @@ -25,6 +25,63 @@ const { variant } = useExperiment(mainBannerExperiment); `WEBVIEW_FILTER_CONFIG`을 수정하면 `Filter.tsx`의 탭 UI와 `webviewRoutes.tsx`의 라우트가 자동으로 반영됨. +## OG 태그 (소셜 미디어 공유 미리보기) + +### 구조 + +React SPA는 클라이언트 사이드 렌더링이라 카카오톡/페이스북 크롤러가 JavaScript를 실행하지 않아 OG 태그를 읽지 못한다. 이를 해결하기 위해 `middleware.ts`(프로젝트 루트)에 Vercel Edge Middleware를 적용했다. + +**요청 흐름:** + +``` +크롤러 요청 → middleware.ts (User-Agent 감지) + → 백엔드 API fetch (timeout 3초) + → OG 태그 포함 HTML 반환 + +일반 브라우저 → middleware.ts → 통과 → index.html (SPA) +``` + +**커버하는 라우트:** + +- `/club/:objectId` — 클럽 상세 (ObjectId) +- `/clubDetail/:objectId` +- `/club/@:clubName` — 클럽 상세 (이름) +- `/clubDetail/@:clubName` + +**감지 크롤러:** KakaoTalk, facebookexternalhit, Twitterbot, LINE, WhatsApp, Telegram, Discord, Slack 등 + +### 새 라우트에 OG 추가 방법 + +`middleware.ts`의 regex와 matcher를 수정한다. + +```ts +// 라우트 추가 +const match = pathname.match(/^\/club(?:Detail)?\/([a-f0-9]{24}|@[^/]+)$/i); + +// config matcher에도 추가 +export const config = { + matcher: ['/club/:path*', '/clubDetail/:path*', '/새라우트/:path*'], +}; +``` + +### Next.js 대비 한계점 + +| 항목 | Vercel Edge Middleware (현재) | Next.js | +| ------------------- | ------------------------------------ | ---------------------------------- | +| **OG 생성 방식** | 크롤러 감지 후 API fetch | `generateMetadata()`로 서버 렌더링 | +| **일반 사용자** | 영향 없음 (SPA 그대로) | SSR로 항상 메타태그 포함 | +| **실행 제한** | 5초, 메모리 128MiB | 제한 없음 (서버 함수) | +| **API 의존성** | API 실패 시 OG 없이 fallback | 서버에서 직접 DB 조회 가능 | +| **User-Agent 오탐** | 새 크롤러 추가 시 수동 업데이트 필요 | 해당 없음 | +| **캐싱** | Edge에서 별도 캐시 없음 | ISR로 캐싱 가능 | +| **커버 범위** | 명시적으로 등록한 라우트만 | 모든 페이지 자동 | + +**현재 구조의 실질적 위험:** + +1. **API 3초 초과 시 OG 미노출** — 백엔드가 느리면 크롤러에게 빈 HTML 반환 +2. **새 크롤러 미감지** — `CRAWLER_PATTERN` regex에 없는 봇은 SPA를 받아 OG 미노출 +3. **라우트 수동 관리** — 새 페이지에 OG가 필요하면 middleware를 직접 수정해야 함 + ## 실시간 업데이트 지원자 상태 업데이트를 위해 SSE(Server-Sent Events) 사용, `AdminClubContext`에서 관리. diff --git a/frontend/docs/features/club-detail/map-event-tracking.md b/frontend/docs/features/club-detail/map-event-tracking.md new file mode 100644 index 000000000..528d847ad --- /dev/null +++ b/frontend/docs/features/club-detail/map-event-tracking.md @@ -0,0 +1,15 @@ +# 동아리방 지도 클릭 이벤트 트래킹 + +동아리 상세 페이지에서 동아리방 위치 지도를 여는 두 가지 진입점에 Mixpanel 이벤트를 적용. + +## 진입점 + +1. **지도 카드** (`Styled.MapCard`) — 상세 페이지 좌측 섹션에 표시되는 네이버 맵 미리보기 클릭 +2. **프로필 카드 내 지도 버튼** (`ClubProfileCard`의 `onMapClick`) — 프로필 카드에서 위치 버튼 클릭 + +두 진입점 모두 `CLUB_MAP_CLICKED` 단일 이벤트로 트래킹. "지도 열람" 행동 자체의 빈도 파악이 목적이므로 진입점을 구분하지 않음. + +## 관련 코드 + +- `src/constants/eventName.ts` — `CLUB_MAP_CLICKED: 'Club Map Clicked'` 이벤트명 정의 +- `src/pages/ClubDetailPage/ClubDetailPage.tsx` — 두 진입점의 클릭 핸들러에 `trackEvent(USER_EVENT.CLUB_MAP_CLICKED)` 추가 diff --git a/frontend/docs/features/event/card-heatmap-tracking.md b/frontend/docs/features/event/card-heatmap-tracking.md new file mode 100644 index 000000000..682315b18 --- /dev/null +++ b/frontend/docs/features/event/card-heatmap-tracking.md @@ -0,0 +1,106 @@ +# 메인페이지 카드 레이아웃 히트맵 트래킹 + +카드 클릭 위치, 스크롤 깊이, 카드 노출 여부를 Mixpanel로 수집해 히트맵 데이터를 구성한다. + +백엔드가 카드 순서를 랜덤으로 반환하므로 `card_index` 단독 분석은 의미 없다. 위치 기반 분석은 `scroll_y` + `card_top_in_viewport` 조합을 사용한다. + +## 수집 이벤트 + +### `ClubCard Clicked` (기존 확장) + +| 속성 | 설명 | +| ----------------------- | ---------------------------------------------------- | +| `club_id` / `club_name` | 어떤 클럽이 클릭됐는지 | +| `card_index` | 랜덤 순서이므로 클럽별 분석용으로만 사용 | +| `scroll_y` | 클릭 시점 페이지 스크롤 Y값 (px) | +| `card_top_in_viewport` | 클릭 시점 뷰포트 기준 카드 상단 위치 (px) | +| `device_type` | mini_mobile / mobile / tablet / laptop / desktop | +| `page` | 이벤트 발생 페이지 (main / webview-main / introduce) | + +### `ClubCard Viewed` (신규) + +카드가 뷰포트에 50% 이상 진입 후 3초 체류 시 1회 발생 (IntersectionObserver + dwell timer). + +| 속성 | 설명 | +| ----------------------- | ---------------------------------------------------- | +| `club_id` / `club_name` | 어떤 클럽이 노출됐는지 | +| `scroll_y` | 이벤트 발생 시점(3초 체류 충족 시) 스크롤 Y값 (px) | +| `card_top_in_viewport` | 카드 진입 시점 뷰포트 기준 카드 상단 위치 (px) | +| `dwell_ms` | 실제 체류 시간 (카드 진입~이벤트 발생, ms) | +| `device_type` | mini_mobile / mobile / tablet / laptop / desktop | +| `page` | 이벤트 발생 페이지 (main / webview-main / introduce) | + +### `Scroll Depth Reached` (신규) + +스크롤 깊이 마일스톤(25 / 50 / 75 / 100%) 도달 시 1회 발생. + +| 속성 | 설명 | +| --------------- | ------------------------------------------------ | +| `depth_percent` | 도달한 스크롤 깊이 | +| `scroll_y` | 해당 시점 스크롤 Y값 (px) | +| `device_type` | mini_mobile / mobile / tablet / laptop / desktop | +| `page` | 이벤트 발생 페이지 | + +## 가능한 분석 + +### 1. 뷰포트 위치 기반 히트맵 + +`ClubCard Clicked`의 `card_top_in_viewport` 분포를 구간으로 나눠 어느 화면 영역에서 클릭이 집중되는지 파악한다. + +```text +0~200px: 상단 (스크롤 없이 보이는 영역) +200~500px: 중단 +500px~: 하단 +``` + +Mixpanel Insights에서 `card_top_in_viewport`를 custom bucket으로 breakdown하면 된다. + +### 2. 클럽별 CTR (노출 대비 클릭률) + +```text +CTR = ClubCard Clicked 수 / ClubCard Viewed 수 (club_id 기준) +``` + +같은 클럽이 여러 유저에게 노출된 횟수 대비 실제 클릭된 횟수로, 카드 순서가 랜덤이어도 클럽 자체의 매력도를 측정할 수 있다. + +Mixpanel Insights에서 두 이벤트를 `club_name`으로 breakdown 후 비율 계산. + +### 3. 스크롤 없이 클릭되는 비율 + +`ClubCard Clicked`에서 `scroll_y = 0`인 이벤트 비율 → 첫 화면에서 바로 클릭하는 유저 비율. + +`scroll_y < 100` 필터로 "사실상 스크롤하지 않은" 케이스를 정의해도 된다. + +### 4. 스크롤 이탈 퍼널 + +`Scroll Depth Reached` 마일스톤별 유저 수로 퍼널을 구성한다. + +```text +25% 도달: X명 +50% 도달: Y명 (이탈률 = 1 - Y/X) +75% 도달: Z명 +100% 도달: W명 +``` + +Mixpanel Funnels에서 `depth_percent = 25 → 50 → 75 → 100` 순서로 설정. + +### 5. 스크롤 깊이별 클릭 패턴 + +`ClubCard Clicked`의 `scroll_y` 분포를 보면 유저가 어느 깊이까지 스크롤한 뒤 클릭하는지 알 수 있다. + +`scroll_y`가 낮은 클릭이 대부분이라면 → 상위 카드 집중도가 높음 +`scroll_y` 분포가 넓게 퍼지면 → 하단 카드도 고르게 탐색됨 + +## 통계 해석 시 주의사항 + +- 클럽별 CTR 비교 시 노출 수(Viewed)가 충분한 클럽만 비교한다. 노출 10회 미만은 노이즈로 간주. +- `card_top_in_viewport`는 뷰포트 크기에 따라 달라지므로 `device_type`으로 필터링해서 디바이스별로 분리해서 보는 것을 권장. +- 필터(카테고리, 검색) 적용 상태에 따라 카드 수와 구성이 달라지므로, 필터 미적용 상태(`category = all`, `keyword = 없음`) 데이터를 기준으로 분석. + +## 관련 코드 + +- `src/pages/MainPage/components/ClubCard/ClubCard.tsx` — 클릭 + impression 트래킹 +- `src/hooks/Mixpanel/useScrollTracking.ts` — 스크롤 깊이 트래킹 훅 +- `src/pages/MainPage/MainPage.tsx` — `index` 전달 + `useScrollTracking` 적용 +- `src/constants/eventName.ts` — 이벤트명 상수 +- `src/utils/getDeviceType.ts` — BREAKPOINT 기반 device_type 판별 diff --git a/frontend/docs/features/hooks/cacheStrategy.md b/frontend/docs/features/hooks/cacheStrategy.md new file mode 100644 index 000000000..d5088dd4b --- /dev/null +++ b/frontend/docs/features/hooks/cacheStrategy.md @@ -0,0 +1,28 @@ +# React Query 캐싱 전략 + +각 훅의 staleTime/gcTime 설정 근거와 패턴 정리. + +## 설정 기준 + +| 데이터 성격 | staleTime | gcTime | 사용 예 | +| --------------------- | ------------------------------ | ------ | -------------------- | +| 정적 데이터 (배너 등) | 1시간 | 1시간 | `useBanner` | +| 일반 비동기 데이터 | 5분 | 기본값 | `useGoogleCalendar` | +| 목록/상세 데이터 | 1분 | 기본값 | 클럽 목록, 클럽 상세 | +| 폴링 병행 데이터 | 1분 (refetchInterval보다 짧게) | 기본값 | `usePromotion` | +| 사용자 입력 반응 | 30초 | 기본값 | 클럽 검색, 자동완성 | +| 실시간 데이터 | 0 | 기본값 | `useGame` | + +## 규칙 + +- `gcTime`은 정적 데이터처럼 메모리에 오래 유지해야 하는 경우에만 `staleTime`과 함께 명시. 나머지는 기본값(5분) 유지. +- `refetchInterval`을 함께 쓸 때는 `staleTime < refetchInterval`이어야 한다. 그렇지 않으면 폴링 후 refetch된 데이터가 여전히 stale로 판단되지 않아 `staleTime`이 의미를 잃는다. +- mutation 후 관련 쿼리는 `invalidateQueries`로 즉시 무효화. + +## 관련 코드 + +- `src/hooks/Queries/useBanner.ts` — staleTime/gcTime 1h (정적 배너) +- `src/hooks/Queries/usePromotion.ts` — staleTime 1min + refetchInterval 3min/5min +- `src/hooks/Queries/useGame.ts` — staleTime 0 + refetchInterval 2s (실시간 랭킹) +- `src/hooks/Queries/useClub.ts` — 클럽 목록/상세 1min, 검색 30s +- `src/hooks/Queries/useGoogleCalendar.ts` — 5min diff --git a/frontend/docs/features/main/banner.md b/frontend/docs/features/main/banner.md new file mode 100644 index 000000000..8f265c41d --- /dev/null +++ b/frontend/docs/features/main/banner.md @@ -0,0 +1,30 @@ +# Banner — 스켈레톤 UI + +배너 데이터 로딩 중 `null` 대신 shimmer 애니메이션이 적용된 스켈레톤 UI를 표시한다. +모바일/데스크탑/랩탑 전 해상도에서 동일하게 동작하며 웹뷰 여부와 무관하게 적용된다. + +## 동작 방식 + +스켈레톤은 두 단계로 나뉘어 노출된다. + +| 단계 | 조건 | 렌더링 | +| ---- | ----------------------------------- | ------------------------------------ | +| 1 | `isPending` | `SkeletonBannerWrapper` (단독) | +| 2 | 데이터 로드 완료 + `!isImageLoaded` | `SkeletonOverlay` (BannerWrapper 위) | +| 3 | 첫 이미지 `onLoad` 발화 | 스켈레톤 제거, 배너 노출 | + +- TanStack Query 캐시 hit 시 `isPending`이 false이므로 1단계 스켈레톤은 노출되지 않음 +- `SkeletonOverlay`는 `BannerWrapper` 내부에 `position: absolute; inset: 0`으로 위치 — Swiper가 뒤에서 정상 초기화되고 이미지를 로드할 수 있음 +- `onLoad`는 `index === 0`인 첫 슬라이드에만 부착 — Swiper `loop` 모드의 슬라이드 복제로 인한 중복 발화 방지 + +## 반응형 + +| 환경 | aspect-ratio | border-radius | +| ------------- | ------------ | ------------- | +| 데스크탑/랩탑 | `1180 / 316` | `26px` | +| 모바일 | `1.8` | `0` | + +## 관련 코드 + +- `src/pages/MainPage/components/Banner/Banner.tsx` — `isPending`, `isImageLoaded` 분기 처리 +- `src/pages/MainPage/components/Banner/Banner.styles.ts` — `SkeletonBannerWrapper`, `SkeletonOverlay`, `shimmer` keyframes diff --git a/frontend/docs/features/main/club-card-tracking.md b/frontend/docs/features/main/club-card-tracking.md new file mode 100644 index 000000000..89ae99166 --- /dev/null +++ b/frontend/docs/features/main/club-card-tracking.md @@ -0,0 +1,40 @@ +# ClubCard 뷰 트래킹 + +ClubCard 컴포넌트의 Mixpanel 인상(impression) 트래킹 구현. + +## 동작 방식 + +IntersectionObserver(threshold 50%)로 카드 진입/이탈을 감지한다. + +- **진입 시**: `intersectStart`, `capturedTop`, `capturedScrollY` 기록. cooldown(2s) 이내 재진입은 무시. +- **이탈 시**: `fireImpressionEvent()` 호출 → `CLUB_CARD_VIEWED` 이벤트 발화. +- **탭 전환/종료 시**: `visibilitychange` 이벤트로 동일하게 처리. +- **언마운트 시**: cleanup에서 `fireImpressionEvent()` 호출. + +## sessionStorage 구조 + +```text +clubcard_last_{page}_{clubId} → 마지막 이벤트 발화 시각 (cooldown 판단용) +clubcard_count_{page}_{clubId} → 탭 세션 내 누적 view_count +``` + +키에 `page`를 포함해 같은 카드가 여러 페이지에 렌더링돼도 독립 집계한다. + +## CLUB_CARD_VIEWED 이벤트 프로퍼티 + +| 프로퍼티 | 설명 | +|----------|------| +| `club_id` / `club_name` | 카드 식별자 | +| `recruitment_status` | 모집 상태 | +| `page` | 렌더링된 페이지 | +| `scroll_y` | 진입 시점 스크롤 위치 | +| `card_top_in_viewport` | 진입 시점 카드 상단 좌표(px) | +| `dwell_ms` | 실제 체류 시간 (진입 → 이탈) | +| `view_count` | 탭 세션 내 누적 조회 횟수 | +| `reentry_count` | `view_count - 1` (재방문 횟수) | +| `device_type` | 디바이스 타입 | + +## 관련 코드 + +- `src/pages/MainPage/components/ClubCard/ClubCard.tsx` — 트래킹 구현 +- `src/constants/eventName.ts` — `CLUB_CARD_VIEWED`, `CLUB_CARD_CLICKED`, `BANNER_NAVIGATION_CLICKED` 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 deleted file mode 100644 index 8c91d0d4b..000000000 --- a/frontend/docs/superpowers/specs/2026-04-14-design-system-toolkit-design.md +++ /dev/null @@ -1,361 +0,0 @@ -# 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/middleware.ts b/frontend/middleware.ts new file mode 100644 index 000000000..0b765543d --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,101 @@ +const CRAWLER_PATTERN = + /bot|crawl|facebookexternalhit|twitterbot|kakao|line|whatsapp|telegram|discord|slack/i; + +const API_BASE = 'https://yourun.shop'; +const SITE_URL = 'https://www.moadong.com'; +const DEFAULT_OG_IMAGE = `${SITE_URL}/og_image.png`; + +function safeDecode(s: string): string { + try { + return decodeURIComponent(s); + } catch { + return s; + } +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +function buildOgHtml(og: { + title: string; + description: string; + image: string; + url: string; +}): string { + const t = escapeHtml(og.title); + const d = escapeHtml(og.description); + const i = escapeHtml(og.image); + const u = escapeHtml(og.url); + return ` + + + + ${t} + + + + + + + + + + + + +`; +} + +export default async function middleware(request: Request) { + const ua = request.headers.get('user-agent') ?? ''; + if (!CRAWLER_PATTERN.test(ua)) return; + + const { pathname } = new URL(request.url); + + // /club/:clubId, /clubDetail/:clubId, /club/@:clubName, /clubDetail/@:clubName 매칭 + const match = pathname.match(/^\/club(?:Detail)?\/([a-f0-9]{24}|@[^/]+)$/i); + if (!match) return; + + const clubId = safeDecode(match[1]); + + try { + const res = await fetch(`${API_BASE}/api/club/${clubId}`, { + signal: AbortSignal.timeout(3000), // 5초 Edge 제한 내 여유있게 3초 + }); + if (!res.ok) return; + + const json = await res.json(); + const club = json?.data?.club; + if (!club) return; + + return new Response( + buildOgHtml({ + title: `${club.name} - 모아동`, + description: + club.introduction || + club.description?.introDescription || + '부경대학교 동아리 정보를 확인해보세요.', + image: club.cover || club.logo || DEFAULT_OG_IMAGE, + url: `${SITE_URL}${safeDecode(pathname)}`, + }), + { + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'public, s-maxage=300, stale-while-revalidate=60', + }, + }, + ); + } catch { + // API 실패 시 SPA로 fallback + return; + } +} + +export const config = { + matcher: ['/club/:path*', '/clubDetail/:path*'], +}; diff --git a/frontend/scripts/cross-review.sh b/frontend/scripts/cross-review.sh new file mode 100755 index 000000000..8dcaeb603 --- /dev/null +++ b/frontend/scripts/cross-review.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# 크로스 에이전트 코드 리뷰: 작성 도구의 반대 CLI로 독립적인 리뷰 수행 +# 사용법: ./scripts/cross-review.sh --writer [--staged] +set -euo pipefail + +# --writer 로 작성 도구 지정 → 반대 CLI로 리뷰 +WRITER="" +STAGED=false +while [ $# -gt 0 ]; do + case "$1" in + --writer) + if [ $# -lt 2 ] || [[ "$2" == --* ]]; then + echo "오류: --writer 옵션에 값이 필요합니다. (허용값: claude | codex)" >&2 + echo "사용법: $0 --writer [--staged]" >&2 + exit 1 + fi + WRITER="$2"; shift 2 ;; + --staged) STAGED=true; shift ;; + *) + echo "오류: 알 수 없는 옵션 '$1'" >&2 + echo "사용법: $0 --writer [--staged]" >&2 + exit 1 ;; + esac +done + +if [ -z "$WRITER" ]; then + echo "오류: --writer 옵션이 필요합니다." >&2 + echo "사용법: $0 --writer [--staged]" >&2 + exit 1 +fi + +case "$WRITER" in + claude|codex) ;; + *) + echo "오류: --writer 허용값은 'claude' 또는 'codex'입니다. 입력값: '$WRITER'" >&2 + exit 1 ;; +esac + +pick_reviewer() { + local prefer="$1" + if [ "$prefer" = "codex" ] && command -v codex &>/dev/null; then + RUN_REVIEW() { codex review "$1"; }; CLI_NAME="codex" + elif [ "$prefer" = "claude" ] && command -v claude &>/dev/null; then + RUN_REVIEW() { claude -p "$1"; }; CLI_NAME="claude" + elif command -v codex &>/dev/null; then + RUN_REVIEW() { codex review "$1"; }; CLI_NAME="codex" + elif command -v claude &>/dev/null; then + RUN_REVIEW() { claude -p "$1"; }; CLI_NAME="claude" + else + echo "오류: claude 또는 codex CLI를 찾을 수 없습니다." >&2 + echo " Claude Code: https://claude.ai/code" >&2 + echo " Codex CLI: https://github.com/openai/codex" >&2 + exit 1 + fi +} + +# 작성 도구의 반대 CLI를 리뷰어로 선택 +case "$WRITER" in + claude) pick_reviewer "codex" ;; + codex) pick_reviewer "claude" ;; + *) pick_reviewer "codex" ;; # 기본값: codex로 리뷰 +esac + +if $STAGED; then + DIFF=$(git diff --staged 2>/dev/null) + SCOPE="스테이징된 변경사항" +else + DIFF=$(git diff HEAD 2>/dev/null) + SCOPE="미커밋 변경사항" + if [ -z "$DIFF" ]; then + DIFF=$(git diff --staged 2>/dev/null) + SCOPE="스테이징된 변경사항" + fi + if [ -z "$DIFF" ]; then + BRANCH=$(git rev-parse --abbrev-ref HEAD) + DIFF=$(git diff origin/develop-fe..."$BRANCH" 2>/dev/null \ + || git diff origin/main..."$BRANCH" 2>/dev/null) + [ -n "$DIFF" ] && SCOPE="현재 브랜치 커밋 변경사항 ($BRANCH)" + fi +fi + + +if [ -z "$DIFF" ]; then + echo "리뷰할 변경사항이 없습니다." + exit 0 +fi + +LINE_COUNT=$(echo "$DIFF" | wc -l | tr -d ' ') +echo "크로스 에이전트 리뷰 시작 [$CLI_NAME] ($SCOPE, ${LINE_COUNT}줄)..." +echo "" + +PROMPT="당신은 10년차 프론트엔드 시니어 개발자입니다. 이 코드를 누가 어떻게 작성했는지 전혀 모릅니다. 아래 git diff를 객관적으로 리뷰하세요. + +**프로젝트 컨텍스트 (React 19 + TypeScript + styled-components):** +- 변수/함수: camelCase | 컴포넌트/타입: PascalCase | 상수: UPPER_SNAKE_CASE +- \`any\` 타입 금지 — 명시적 TypeScript 타입 필수 +- 상수는 반드시 \`src/constants/\`에서 관리 +- 스타일링은 styled-components + 테마 시스템 (\`src/styles/theme/\`) +- API 호출은 \`handleResponse()\`, \`secureFetch()\` 헬퍼 사용 +- Mixpanel 이벤트명은 \`src/constants/eventName.ts\`에서 가져오기 — 문자열 하드코딩 금지 +- 서버 상태: React Query v5 | 클라이언트 상태: Zustand + +**리뷰 기준:** +1. CRITICAL — 버그, 로직 오류, 보안 취약점, TypeScript 타입 오류 +2. WARNING — 컨벤션 위반, 성능 이슈, 하드코딩 문자열 +3. INFO — 코드 스타일, 중복 코드, 네이밍 + +**출력 형식:** +심각도별 그룹화. 각 항목: \`[심각도] 파일경로:라인 — 설명\` +마지막에 한 줄 요약: 총 발견 건수 + 가장 먼저 수정할 항목. +모든 출력은 한국어로 작성하세요. + +Diff: +--- +$DIFF" + +RUN_REVIEW "$PROMPT" diff --git a/frontend/scripts/jira-story.sh b/frontend/scripts/jira-story.sh new file mode 100755 index 000000000..38a5fd8c7 --- /dev/null +++ b/frontend/scripts/jira-story.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Jira 스토리 생성 스크립트 +# 사용법: ./scripts/jira-story.sh "제목" "설명" "인수조건" "에픽키(선택)" +# +# 필수 환경 변수 (.env 또는 shell에서 설정): +# JIRA_HOST - Atlassian 인스턴스 호스트명 (예: yourcompany.atlassian.net) +# PROJECT_KEY - Jira 프로젝트 키 (예: MOA) +# JIRA_EMAIL - Atlassian 계정 이메일 +# JIRA_API_TOKEN - Atlassian API 토큰 (https://id.atlassian.com/manage-profile/security/api-tokens) +# JIRA_BOARD_ID - Jira 보드 ID (활성 스프린트 자동 조회용) +# JIRA_ASSIGNEE_ID - 담당자 Jira account ID +set -euo pipefail + +# 프로젝트 루트의 .env 자동 로드 +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +[ -f "$PROJECT_ROOT/.env" ] && source "$PROJECT_ROOT/.env" + +JIRA_HOST="${JIRA_HOST:-}" +PROJECT_KEY="${PROJECT_KEY:-}" +ISSUE_TYPE="Story" + +SUMMARY="${1:-}" +DESCRIPTION="${2:-}" +AC="${3:-}" +EPIC_KEY="${4:-}" + +if [ -z "$SUMMARY" ]; then + echo "오류: 스토리 제목이 필요합니다." >&2 + exit 1 +fi + +if [ -z "${JIRA_HOST:-}" ] || [ -z "${PROJECT_KEY:-}" ]; then + echo "오류: JIRA_HOST와 PROJECT_KEY 환경 변수를 설정하세요." >&2 + echo " export JIRA_HOST=yourcompany.atlassian.net" >&2 + echo " export PROJECT_KEY=YOUR_PROJECT_KEY" >&2 + exit 1 +fi + +if [ -z "${JIRA_EMAIL:-}" ] || [ -z "${JIRA_API_TOKEN:-}" ]; then + echo "오류: JIRA_EMAIL과 JIRA_API_TOKEN 환경 변수를 설정하세요." >&2 + echo " export JIRA_EMAIL=your@email.com" >&2 + echo " export JIRA_API_TOKEN=your_api_token" >&2 + echo " API 토큰 발급: https://id.atlassian.com/manage-profile/security/api-tokens" >&2 + exit 1 +fi + +if ! command -v jq &>/dev/null; then + echo "오류: jq가 필요합니다." >&2 + case "$(uname -s)" in + Darwin*) echo " brew install jq" >&2 ;; + MINGW*|MSYS*|CYGWIN*) echo " winget install jqlang.jq" >&2 ;; + Linux*) echo " sudo apt install jq 또는 sudo yum install jq" >&2 ;; + *) echo " https://jqlang.github.io/jq/download/" >&2 ;; + esac + exit 1 +fi + +# 활성 스프린트 자동 조회 +SPRINT_ID="" +if [ -n "${JIRA_BOARD_ID:-}" ]; then + SPRINT_RESPONSE=$(curl -s \ + --connect-timeout 5 \ + --max-time 10 \ + -u "${JIRA_EMAIL}:${JIRA_API_TOKEN}" \ + "https://${JIRA_HOST}/rest/agile/1.0/board/${JIRA_BOARD_ID}/sprint?state=active" 2>/dev/null || true) + + if [ -n "$SPRINT_RESPONSE" ]; then + SPRINT_ID=$(echo "$SPRINT_RESPONSE" | jq -r '.values[0].id // empty' 2>/dev/null || true) + SPRINT_NAME=$(echo "$SPRINT_RESPONSE" | jq -r '.values[0].name // empty' 2>/dev/null || true) + fi + + if [ -n "$SPRINT_ID" ]; then + echo "📋 활성 스프린트: ${SPRINT_NAME} (ID: ${SPRINT_ID})" + else + echo "⚠️ 활성 스프린트가 없어 백로그에 추가됩니다." + fi +fi + +# ADF(Atlassian Document Format) 본문 구성 +build_adf_content() { + local desc="$1" + local ac="$2" + local content="[]" + + if [ -n "$desc" ]; then + content=$(echo "$content" | jq \ + --arg text "$desc" \ + '. + [{"type":"paragraph","content":[{"type":"text","text":$text}]}]') + fi + + if [ -n "$ac" ]; then + content=$(echo "$content" | jq \ + '. + [{"type":"paragraph","content":[{"type":"text","text":"✅ 인수 조건","marks":[{"type":"strong"}]}]}]') + content=$(echo "$content" | jq \ + --arg text "$ac" \ + '. + [{"type":"paragraph","content":[{"type":"text","text":$text}]}]') + fi + + echo "$content" +} + +ADF_CONTENT=$(build_adf_content "$DESCRIPTION" "$AC") + +# payload를 파일로 저장 (Windows 셸 호환) +PAYLOAD_FILE=$(mktemp) +jq -n \ + --arg summary "$SUMMARY" \ + --arg project "$PROJECT_KEY" \ + --arg issuetype "$ISSUE_TYPE" \ + --argjson content "$ADF_CONTENT" \ + --argjson sprintId "${SPRINT_ID:-null}" \ + --arg assigneeId "${JIRA_ASSIGNEE_ID:-}" \ + --arg epicKey "${EPIC_KEY:-}" \ + '{ + fields: { + project: { key: $project }, + summary: $summary, + issuetype: { name: $issuetype }, + description: { + type: "doc", + version: 1, + content: $content + } + } + } + | if $sprintId != null then .fields.customfield_10020 = $sprintId else . end + | if $assigneeId != "" then .fields.assignee = { accountId: $assigneeId } else . end + | if $epicKey != "" then .fields.parent = { key: $epicKey } else . end' \ + > "$PAYLOAD_FILE" + +TMPFILE=$(mktemp) +HTTP_CODE=$(curl -s -o "$TMPFILE" -w "%{http_code}" \ + --connect-timeout 5 \ + --max-time 30 \ + -X POST \ + -u "${JIRA_EMAIL}:${JIRA_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d @"$PAYLOAD_FILE" \ + "https://${JIRA_HOST}/rest/api/3/issue") +BODY=$(cat "$TMPFILE") +rm -f "$TMPFILE" "$PAYLOAD_FILE" + +if [ "$HTTP_CODE" = "201" ]; then + ISSUE_KEY=$(echo "$BODY" | jq -r '.key') + echo "✅ 스토리 생성 완료: $ISSUE_KEY" + echo "🔗 https://${JIRA_HOST}/browse/${ISSUE_KEY}" +else + echo "오류: 스토리 생성 실패 (HTTP $HTTP_CODE)" >&2 + echo "$BODY" | jq -r '.errors // .errorMessages // .' >&2 + exit 1 +fi diff --git a/frontend/scripts/jira-task.sh b/frontend/scripts/jira-task.sh new file mode 100755 index 000000000..7480b7ed1 --- /dev/null +++ b/frontend/scripts/jira-task.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# GitHub 이슈 생성으로 Jira 하위 작업 + 브랜치 자동 생성 +# 사용법: ./scripts/jira-task.sh "제목" "담당자" "MOA-xxx" "브랜치명" ["분기브랜치"] ["설명"] ["태스크"] ["라벨(쉼표구분)"] ["마감일"] +# +# GitHub Actions(common-jira-create.yml)이 이슈를 감지해 자동으로: +# - Jira 하위 작업 생성 (상위 스토리의 하위) +# - Git 브랜치 생성 +set -euo pipefail + +TITLE="${1:-}" +ASSIGNEE="${2:-}" +PARENT_KEY="${3:-}" +BRANCH="${4:-}" +BASE_BRANCH="${5:-develop-fe}" +DESCRIPTION="${6:-}" +TASKS="${7:-- [ ] Task1}" +LABEL="${8:-✨ Feature}" +DUE_DATE="${9:-}" +REPO="Moadong/moadong" + +if [ -z "$TITLE" ] || [ -z "$ASSIGNEE" ] || [ -z "$PARENT_KEY" ] || [ -z "$BRANCH" ]; then + echo "사용법: ./scripts/jira-task.sh \"제목\" \"담당자\" \"MOA-xxx\" \"브랜치명\" [\"분기브랜치\"] [\"설명\"] [\"태스크\"]" >&2 + exit 1 +fi + +if [[ "$PARENT_KEY" != MOA-* ]]; then + echo "오류: 상위 스토리 키는 MOA- 로 시작해야 합니다. (입력값: $PARENT_KEY)" >&2 + exit 1 +fi + +if ! command -v gh &>/dev/null; then + echo "오류: GitHub CLI(gh)가 필요합니다. brew install gh" >&2 + exit 1 +fi + +BODY=$(cat < - + + + diff --git a/frontend/src/components/common/Header/Header.tsx b/frontend/src/components/common/Header/Header.tsx index 33a69a3bc..e42cbb152 100644 --- a/frontend/src/components/common/Header/Header.tsx +++ b/frontend/src/components/common/Header/Header.tsx @@ -25,6 +25,7 @@ const Header = ({ showOn, hideOn }: HeaderProps) => { handleIntroduceClick, handleClubUnionClick, handlePromotionClick, + handleMenuOpen, handleMenuClose, } = useHeaderNavigation(); @@ -53,11 +54,12 @@ const Header = ({ showOn, hideOn }: HeaderProps) => { setIsMenuOpen(false); }; const toggleMenu = () => { - setIsMenuOpen((prev) => { - const next = !prev; - if (prev && !next) handleMenuClose(); - return next; - }); + if (isMenuOpen) { + handleMenuClose(); + } else { + handleMenuOpen(); + } + setIsMenuOpen((prev) => !prev); }; return ( diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts index 566651d00..c93e86326 100644 --- a/frontend/src/constants/eventName.ts +++ b/frontend/src/constants/eventName.ts @@ -1,6 +1,6 @@ export const USER_EVENT = { CATEGORY_BUTTON_CLICKED: 'CategoryButton Clicked', - SEARCH_BOX_CLICKED: 'SearchBox Clicked', + SEARCH_EXCUTED: 'Search Executed', // 메인 페이지 팝업 MAIN_POPUP_VIEWED: 'Main Popup Viewed', @@ -17,6 +17,7 @@ export const USER_EVENT = { // 배너 클릭 BANNER_CLICKED: 'Banner Clicked', APP_DOWNLOAD_BANNER_CLICKED: 'App Download Banner Clicked', + BANNER_NAVIGATION_CLICKED: 'Banner Navigation Clicked', // 네비게이션 BACK_BUTTON_CLICKED: 'Back Button Clicked', @@ -30,10 +31,15 @@ export const USER_EVENT = { TAB_CLICKED: 'Tab Clicked', PHOTO_NAVIGATION_CLICKED: 'Photo Navigation', CLUB_CARD_CLICKED: 'ClubCard Clicked', + CLUB_CARD_VIEWED: 'ClubCard Viewed', + SCROLL_DEPTH_REACHED: 'Scroll Depth Reached', CLUB_INTRO_TAB_CLICKED: 'Club Intro Tab Clicked', CLUB_FEED_TAB_CLICKED: 'Club Feed Tab Clicked', CLUB_SCHEDULE_TAB_CLICKED: 'Club Schedule Tab Clicked', + // 동아리방 지도 + CLUB_MAP_CLICKED: 'Club Map Clicked', + // 동아리 지원 CLUB_APPLY_BUTTON_CLICKED: 'Club Apply Button Clicked', RECOMMENDED_CLUB_CLICKED: 'Recommended Club Clicked', @@ -143,3 +149,9 @@ export const PAGE_VIEW = { PHOTO_EDIT_PAGE: '동아리 활동 사진 수정 페이지', ADMIN_ACCOUNT_EDIT_PAGE: '관리자 계정 수정 페이지', } as const; + +export const PAGE_NAME = { + MAIN: 'main', + WEBVIEW_MAIN: 'webview-main', + INTRODUCE: 'introduce', +} as const; diff --git a/frontend/src/hooks/Header/useHeaderNavigation.ts b/frontend/src/hooks/Header/useHeaderNavigation.ts index 1b1722f84..733546141 100644 --- a/frontend/src/hooks/Header/useHeaderNavigation.ts +++ b/frontend/src/hooks/Header/useHeaderNavigation.ts @@ -37,6 +37,10 @@ const useHeaderNavigation = () => { trackEvent(USER_EVENT.ADMIN_BUTTON_CLICKED); }, [navigate, trackEvent]); + const handleMenuOpen = useCallback(() => { + trackEvent(USER_EVENT.MOBILE_MENU_BUTTON_CLICKED); + }, [trackEvent]); + const handleMenuClose = useCallback(() => { trackEvent(USER_EVENT.MOBILE_MENU_DELETE_BUTTON_CLICKED); }, [trackEvent]); @@ -47,6 +51,7 @@ const useHeaderNavigation = () => { handleClubUnionClick, handlePromotionClick, handleAdminClick, + handleMenuOpen, handleMenuClose, }; }; diff --git a/frontend/src/hooks/Mixpanel/useMixpanelTrack.ts b/frontend/src/hooks/Mixpanel/useMixpanelTrack.ts index 2b8168ecb..610631ad3 100644 --- a/frontend/src/hooks/Mixpanel/useMixpanelTrack.ts +++ b/frontend/src/hooks/Mixpanel/useMixpanelTrack.ts @@ -7,7 +7,6 @@ const useMixpanelTrack = () => { try { mixpanel.track(eventName, { ...properties, - distinct_id: mixpanel.get_distinct_id(), timestamp: Date.now(), url: window.location.href, }); diff --git a/frontend/src/hooks/Mixpanel/useScrollTracking.ts b/frontend/src/hooks/Mixpanel/useScrollTracking.ts new file mode 100644 index 000000000..f988138cd --- /dev/null +++ b/frontend/src/hooks/Mixpanel/useScrollTracking.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; +import { USER_EVENT } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import getDeviceType from '@/utils/getDeviceType'; + +const DEPTH_MILESTONES = [25, 50, 75, 100] as const; + +const useScrollTracking = (page?: string) => { + const reached = useRef(new Set()); + const trackEvent = useMixpanelTrack(); + + useEffect(() => { + reached.current = new Set(); + + const handleScroll = () => { + const scrollY = window.scrollY; + const docHeight = + document.documentElement.scrollHeight - window.innerHeight; + if (docHeight <= 0) return; + + const percent = Math.round((scrollY / docHeight) * 100); + + DEPTH_MILESTONES.forEach((milestone) => { + if (percent >= milestone && !reached.current.has(milestone)) { + reached.current.add(milestone); + trackEvent(USER_EVENT.SCROLL_DEPTH_REACHED, { + depth_percent: milestone, + scroll_y: Math.round(scrollY), + page, + device_type: getDeviceType(), + }); + } + }); + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + handleScroll(); + return () => window.removeEventListener('scroll', handleScroll); + }, [page, trackEvent]); +}; + +export default useScrollTracking; diff --git a/frontend/src/hooks/Queries/useBanner.ts b/frontend/src/hooks/Queries/useBanner.ts index aa6d1cfbb..75815efff 100644 --- a/frontend/src/hooks/Queries/useBanner.ts +++ b/frontend/src/hooks/Queries/useBanner.ts @@ -6,7 +6,7 @@ export const useGetBanners = (type: BannerType = 'WEB') => { return useQuery({ queryKey: queryKeys.banner.list(type), queryFn: () => bannerApi.getBanners(type), - staleTime: 24 * 60 * 60 * 1000, - gcTime: 24 * 60 * 60 * 1000, + staleTime: 60 * 60 * 1000, + gcTime: 60 * 60 * 1000, }); }; diff --git a/frontend/src/hooks/Queries/usePromotion.ts b/frontend/src/hooks/Queries/usePromotion.ts index 5a3f325d2..0a2d2738a 100644 --- a/frontend/src/hooks/Queries/usePromotion.ts +++ b/frontend/src/hooks/Queries/usePromotion.ts @@ -14,7 +14,7 @@ export const useGetPromotionArticles = () => { return useQuery({ queryKey: queryKeys.promotion.list(), queryFn: getPromotionArticles, - staleTime: 1000 * 60 * 5, + staleTime: 60 * 1000, refetchOnWindowFocus: true, refetchInterval: isPromotionPage ? 180000 : 300000, refetchIntervalInBackground: false, diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index a4f3ffaa3..052c7311c 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -150,15 +150,24 @@ const ClubDetailPage = () => { name={clubDetail.name} logo={clubDetail.logo} cover={clubDetail.cover} + category={clubDetail.category} recruitmentStatus={clubDetail.recruitmentStatus} socialLinks={clubDetail.socialLinks} introDescription={clubDetail.description.introDescription} location={clubLocation} - onMapClick={() => setIsMapModalOpen(true)} + onMapClick={() => { + setIsMapModalOpen(true); + trackEvent(USER_EVENT.CLUB_MAP_CLICKED); + }} /> {clubLocation && ( - setIsMapModalOpen(true)}> + { + setIsMapModalOpen(true); + trackEvent(USER_EVENT.CLUB_MAP_CLICKED); + }} + > diff --git a/frontend/src/pages/ClubDetailPage/components/ClubDetailTopBar/ClubDetailTopBar.tsx b/frontend/src/pages/ClubDetailPage/components/ClubDetailTopBar/ClubDetailTopBar.tsx index 5893fe9f4..b3035cd6a 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubDetailTopBar/ClubDetailTopBar.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubDetailTopBar/ClubDetailTopBar.tsx @@ -4,6 +4,8 @@ import { useTheme } from 'styled-components'; import NotificationIcon from '@/assets/images/icons/notification_icon.svg?react'; import PrevButtonIcon from '@/assets/images/icons/prev_button_icon.svg?react'; import Spinner from '@/components/common/Spinner/Spinner'; +import { USER_EVENT } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useScrollTrigger } from '@/hooks/Scroll/useScrollTrigger'; import useOpenAppFromKakao from '@/hooks/useOpenAppFromKakao'; import isInAppWebView from '@/utils/isInAppWebView'; @@ -48,6 +50,7 @@ const ClubDetailTopBar = ({ const isInApp = isInAppWebView(); const isKakao = !isInApp && isKakaoTalkBrowser(); const { openApp, isLoading } = useOpenAppFromKakao(); + const trackEvent = useMixpanelTrack(); const [isNotificationActive, setIsNotificationActive] = useState(initialIsSubscribed); @@ -63,7 +66,7 @@ const ClubDetailTopBar = ({ }); const handleBackClick = () => { - // 앱 웹뷰면 앱에 뒤로가기 요청, 아니면 웹 네비게이션 + trackEvent(USER_EVENT.BACK_BUTTON_CLICKED); const handled = requestNavigateBack(); if (!handled) { // 히스토리 스택이 있으면 뒤로가기, 없으면(직접 진입 등) 메인으로 이동 diff --git a/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.styles.ts b/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.styles.ts index 6bb09e0e0..3a78f3a98 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.styles.ts @@ -21,13 +21,22 @@ export const CoverImageWrapper = styled.div` `; export const CoverImage = styled.img` + display: block; width: 100%; - height: 213px; + height: 220px; position: relative; z-index: 1; object-fit: cover; `; +export const CoverFallback = styled.div<{ $color?: string }>` + width: 100%; + height: 220px; + position: relative; + z-index: 1; + background-color: ${({ $color }) => $color || colors.gray[400]}; +`; + export const LogoWrapper = styled.div` position: absolute; top: 165px; @@ -35,9 +44,13 @@ export const LogoWrapper = styled.div` width: 64px; height: 64px; border-radius: 16px; - background-color: ${colors.base.white}; - padding: 2px; + background-color: ${colors.gray[100]}; + padding: 3.5px; z-index: 3; + + ${media.tablet} { + background-color: ${colors.base.white}; + } `; export const Logo = styled.img` @@ -214,6 +227,8 @@ export const LocationInfo = styled.div` gap: 5px; cursor: default; user-select: none; + min-width: 0; + overflow: hidden; img { width: 12px; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.tsx b/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.tsx index 6eb34f730..eb573af6f 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.tsx @@ -1,13 +1,14 @@ import locationIcon from '@/assets/images/icons/location_icon.svg'; import InstagramIcon from '@/assets/images/icons/sns/instagram_icon.svg'; import YoutubeIcon from '@/assets/images/icons/sns/youtube_icon.svg'; -import DefaultCover from '@/assets/images/logos/default_cover_image.png'; +import defaultCover from '@/assets/images/logos/default_cover_image.png'; import DefaultLogo from '@/assets/images/logos/default_profile_image.svg'; import ClubStateBox from '@/components/ClubStateBox/ClubStateBox'; import { ClubLocation } from '@/constants/clubLocation'; import { USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useNavigator from '@/hooks/useNavigator'; +import { TAG_COLORS } from '@/styles/clubTags'; import { SNSPlatform } from '@/types/club'; import * as Styled from './ClubProfileCard.styles'; @@ -15,6 +16,7 @@ interface ClubProfileCardProps { name: string; logo?: string; cover?: string; + category?: string; recruitmentStatus: string; socialLinks: Record; introDescription: string; @@ -26,6 +28,7 @@ const ClubProfileCard = ({ name, logo, cover, + category, recruitmentStatus, socialLinks, introDescription, @@ -61,7 +64,13 @@ const ClubProfileCard = ({ return ( - + {cover ? ( + + ) : category && TAG_COLORS[category] ? ( + + ) : ( + + )} diff --git a/frontend/src/pages/IntroducePage/components/sections/1.IntroSection/IntroSection.tsx b/frontend/src/pages/IntroducePage/components/sections/1.IntroSection/IntroSection.tsx index 6bc0264b7..8dee68186 100644 --- a/frontend/src/pages/IntroducePage/components/sections/1.IntroSection/IntroSection.tsx +++ b/frontend/src/pages/IntroducePage/components/sections/1.IntroSection/IntroSection.tsx @@ -1,6 +1,7 @@ import { useNavigate } from 'react-router-dom'; import search_button_icon from '@/assets/images/icons/search_button_icon.svg'; import introduce_phone_mockup from '@/assets/images/introduce/introduce_phone_mockup.png'; +import { PAGE_NAME } from '@/constants/eventName'; import { BackgroundCircleLarge, BackgroundCircleSmall, @@ -111,7 +112,7 @@ const IntroSection = () => { variants={fadeUp} {...cardPositions[index]} > - + ))} diff --git a/frontend/src/pages/MainPage/MainPage.tsx b/frontend/src/pages/MainPage/MainPage.tsx index b104f7cc9..698ef4f05 100644 --- a/frontend/src/pages/MainPage/MainPage.tsx +++ b/frontend/src/pages/MainPage/MainPage.tsx @@ -3,7 +3,8 @@ import Filter from '@/components/common/Filter/Filter'; import Footer from '@/components/common/Footer/Footer'; import Header from '@/components/common/Header/Header'; import Spinner from '@/components/common/Spinner/Spinner'; -import { PAGE_VIEW } from '@/constants/eventName'; +import { PAGE_NAME, PAGE_VIEW } from '@/constants/eventName'; +import useScrollTracking from '@/hooks/Mixpanel/useScrollTracking'; import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; import { useGetCardList } from '@/hooks/Queries/useClub'; import usePromotionNotification from '@/hooks/Queries/usePromotionNotification'; @@ -19,6 +20,7 @@ import * as Styled from './MainPage.styles'; const MainPage = () => { useTrackPageView(PAGE_VIEW.MAIN_PAGE); + useScrollTracking(PAGE_NAME.MAIN); const { selectedCategory } = useSelectedCategory(); const { keyword } = useSearchKeyword(); @@ -46,7 +48,9 @@ const MainPage = () => { const clubList = useMemo(() => { if (!hasData) return null; - return clubs.map((club: Club) => ); + return clubs.map((club: Club, i: number) => ( + + )); }, [clubs, hasData]); return ( diff --git a/frontend/src/pages/MainPage/components/Banner/Banner.styles.ts b/frontend/src/pages/MainPage/components/Banner/Banner.styles.ts index 3ac830ab3..a9cff0071 100644 --- a/frontend/src/pages/MainPage/components/Banner/Banner.styles.ts +++ b/frontend/src/pages/MainPage/components/Banner/Banner.styles.ts @@ -1,6 +1,35 @@ -import styled from 'styled-components'; +import styled, { keyframes } from 'styled-components'; import { media } from '@/styles/mediaQuery'; +const shimmer = keyframes` + 0% { background-position: -800px 0; } + 100% { background-position: 800px 0; } +`; + +export const SkeletonBannerWrapper = styled.div` + width: 100%; + max-width: 1180px; + aspect-ratio: 1180 / 316; + border-radius: 26px; + background: linear-gradient(90deg, #f0f0f0 25%, #e4e4e4 50%, #f0f0f0 75%); + background-size: 800px 100%; + animation: ${shimmer} 1.5s infinite linear; + + ${media.mobile} { + aspect-ratio: 1.8; + border-radius: 0; + } +`; + +export const SkeletonOverlay = styled.div` + position: absolute; + inset: 0; + z-index: 10; + background: linear-gradient(90deg, #f0f0f0 25%, #e4e4e4 50%, #f0f0f0 75%); + background-size: 800px 100%; + animation: ${shimmer} 1.5s infinite linear; +`; + export const BannerContainer = styled.div` max-width: 1180px; margin: 0 auto; diff --git a/frontend/src/pages/MainPage/components/Banner/Banner.tsx b/frontend/src/pages/MainPage/components/Banner/Banner.tsx index 5bba0262d..5dd2ea91f 100644 --- a/frontend/src/pages/MainPage/components/Banner/Banner.tsx +++ b/frontend/src/pages/MainPage/components/Banner/Banner.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, type SyntheticEvent } from 'react'; import type { Swiper as SwiperType } from 'swiper'; import { Autoplay, Navigation } from 'swiper/modules'; import { Swiper, SwiperSlide } from 'swiper/react'; @@ -23,8 +23,9 @@ const Banner = ({ isWebview = false }: BannerProps) => { const trackEvent = useMixpanelTrack(); const [swiperInstance, setSwiperInstance] = useState(null); const [currentIndex, setCurrentIndex] = useState(0); + const [isImageLoaded, setIsImageLoaded] = useState(false); const bannerType = isWebview ? 'APP_HOME' : isMobile ? 'WEB_MOBILE' : 'WEB'; - const { data: banners, isLoading, isFetched } = useGetBanners(bannerType); + const { data: banners, isPending, isFetched } = useGetBanners(bannerType); const fallbackBanners = BANNERS.map((banner) => ({ id: banner.id, @@ -43,10 +44,26 @@ const Banner = ({ isWebview = false }: BannerProps) => { const handlePrev = () => { swiperInstance?.slidePrev(); + trackEvent(USER_EVENT.BANNER_NAVIGATION_CLICKED, { + direction: 'prev', + from_index: currentIndex, + }); }; const handleNext = () => { swiperInstance?.slideNext(); + trackEvent(USER_EVENT.BANNER_NAVIGATION_CLICKED, { + direction: 'next', + from_index: currentIndex, + }); + }; + + const handleImageError = ( + e: SyntheticEvent, + index: number, + ) => { + if (index === 0) setIsImageLoaded(true); + e.currentTarget.style.display = 'none'; }; const handleBannerClick = ( @@ -81,8 +98,12 @@ const Banner = ({ isWebview = false }: BannerProps) => { handleLink(url); }; - if (isLoading) { - return null; + if (isPending) { + return ( + + + + ); } if (displayBanners?.length === 0) { @@ -92,6 +113,7 @@ const Banner = ({ isWebview = false }: BannerProps) => { return ( + {!isImageLoaded && } @@ -112,7 +134,7 @@ const Banner = ({ isWebview = false }: BannerProps) => { }} speed={500} > - {displayBanners?.map((banner) => ( + {displayBanners?.map((banner, index) => ( { ) } > - {banner.alt} + {banner.alt} setIsImageLoaded(true) : undefined + } + onError={(e) => handleImageError(e, index)} + /> ))} diff --git a/frontend/src/pages/MainPage/components/ClubCard/ClubCard.tsx b/frontend/src/pages/MainPage/components/ClubCard/ClubCard.tsx index 019e27b11..3133c69c8 100644 --- a/frontend/src/pages/MainPage/components/ClubCard/ClubCard.tsx +++ b/frontend/src/pages/MainPage/components/ClubCard/ClubCard.tsx @@ -1,30 +1,119 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import mixpanel from 'mixpanel-browser'; import default_profile_image from '@/assets/images/logos/default_profile_image.svg'; import ClubStateBox from '@/components/ClubStateBox/ClubStateBox'; import ClubTag from '@/components/ClubTag/ClubTag'; import { USER_EVENT } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import ClubLogo from '@/pages/MainPage/components/ClubLogo/ClubLogo'; import { Club } from '@/types/club'; +import getDeviceType from '@/utils/getDeviceType'; import * as Styled from './ClubCard.styles'; interface ClubCardProps { club: Club; + index?: number; + page?: string; children?: React.ReactNode; onCardClick?: (club: Club) => void; } -const ClubCard = ({ club, children, onCardClick }: ClubCardProps) => { +const COOLDOWN_MS = 2_000; // IntersectionObserver jitter 방지 +const IMPRESSION_THRESHOLD = 0.5; // IAB 뷰어빌리티 기준 (50% in-view) +const MIN_DWELL_MS = 300; // 안구 고정 최소 시간 기반, fly-by 스크롤 제외 + +const ClubCard = ({ + club, + index, + page, + children, + onCardClick, +}: ClubCardProps) => { const navigate = useNavigate(); + const trackEvent = useMixpanelTrack(); const [isClicked, setIsClicked] = useState(false); + const containerRef = useRef(null); + + const SS_LAST_KEY = `clubcard_last_${page ?? 'default'}_${club.id}`; + const SS_COUNT_KEY = `clubcard_count_${page ?? 'default'}_${club.id}`; + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + let intersectStart: number | null = null; + let capturedTop: number | null = null; + let capturedScrollY: number | null = null; + + const fireImpressionEvent = () => { + if (intersectStart === null) return; + const dwell_ms = Date.now() - intersectStart; + intersectStart = null; + if (dwell_ms < MIN_DWELL_MS) return; + const count = + parseInt(sessionStorage.getItem(SS_COUNT_KEY) ?? '0', 10) + 1; + sessionStorage.setItem(SS_LAST_KEY, String(Date.now())); + sessionStorage.setItem(SS_COUNT_KEY, String(count)); + trackEvent(USER_EVENT.CLUB_CARD_VIEWED, { + club_id: club.id, + club_name: club.name, + recruitment_status: club.recruitmentStatus, + page, + scroll_y: capturedScrollY, + card_top_in_viewport: capturedTop, + dwell_ms, + view_count: count, + reentry_count: Math.max(0, count - 1), + device_type: getDeviceType(), + }); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') fireImpressionEvent(); + }; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + if (intersectStart !== null) return; + const lastTime = parseInt( + sessionStorage.getItem(SS_LAST_KEY) ?? '0', + 10, + ); + if (Date.now() - lastTime < COOLDOWN_MS) return; + intersectStart = Date.now(); + capturedTop = Math.round(entry.boundingClientRect.top); + capturedScrollY = Math.round(window.scrollY); + } else { + fireImpressionEvent(); + } + }, + { threshold: IMPRESSION_THRESHOLD }, + ); + + observer.observe(el); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + observer.disconnect(); + document.removeEventListener('visibilitychange', handleVisibilityChange); + fireImpressionEvent(); + }; + }, [club.id, club.name, club.recruitmentStatus, page]); const handleClick = () => { setIsClicked(true); - mixpanel.track(USER_EVENT.CLUB_CARD_CLICKED, { + trackEvent(USER_EVENT.CLUB_CARD_CLICKED, { club_id: club.id, club_name: club.name, recruitment_status: club.recruitmentStatus, + page, + card_index: index, + scroll_y: Math.round(window.scrollY), + card_top_in_viewport: (() => { + const rect = containerRef.current?.getBoundingClientRect(); + return rect ? Math.round(rect.top) : undefined; + })(), + device_type: getDeviceType(), }); setTimeout(() => { @@ -39,6 +128,7 @@ const ClubCard = ({ club, children, onCardClick }: ClubCardProps) => { return ( { setSelectedCategory('all'); setIsSearching(true); - trackEvent('Search Executed', { + trackEvent(USER_EVENT.SEARCH_EXCUTED, { inputValue: inputValue, page: pathname, }); diff --git a/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx b/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx index 3f808f3ba..2f8aa62fe 100644 --- a/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx +++ b/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from 'react-router-dom'; import { USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useNavigator from '@/hooks/useNavigator'; @@ -13,6 +14,7 @@ interface Props { const PromotionClubCTA = ({ clubId, clubName }: Props) => { const handleLink = useNavigator(); + const navigate = useNavigate(); const trackEvent = useMixpanelTrack(); const handleNavigate = () => { @@ -21,9 +23,11 @@ const PromotionClubCTA = ({ clubId, clubName }: Props) => { club_name: clubName, }); - // 웹뷰는 club/id 기반 slug, 일반 웹은 clubName 기반 경로 사용 if (isInAppWebView()) { - requestNavigateWebview(`club/${clubId}`); + const sent = requestNavigateWebview( + `club/@${encodeURIComponent(clubName)}`, + ); + if (!sent) navigate(`/clubDetail/@${encodeURIComponent(clubName)}`); } else { handleLink(`/clubDetail/@${encodeURIComponent(clubName)}`); } diff --git a/frontend/src/pages/PromotionPage/components/detail/PromotionDetailTopBar/PromotionDetailTopBar.tsx b/frontend/src/pages/PromotionPage/components/detail/PromotionDetailTopBar/PromotionDetailTopBar.tsx index 803b70ff4..bcb8186d3 100644 --- a/frontend/src/pages/PromotionPage/components/detail/PromotionDetailTopBar/PromotionDetailTopBar.tsx +++ b/frontend/src/pages/PromotionPage/components/detail/PromotionDetailTopBar/PromotionDetailTopBar.tsx @@ -1,12 +1,16 @@ import { useNavigate } from 'react-router-dom'; import PrevButtonIcon from '@/assets/images/icons/prev_button_icon.svg?react'; +import { USER_EVENT } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { requestNavigateBack } from '@/utils/webviewBridge'; import * as Styled from './PromotionDetailTopBar.styles'; const PromotionDetailTopBar = () => { const navigate = useNavigate(); + const trackEvent = useMixpanelTrack(); const handleBackClick = () => { + trackEvent(USER_EVENT.BACK_BUTTON_CLICKED); const handled = requestNavigateBack(); if (!handled) { if (window.history.state && window.history.state.idx > 0) { diff --git a/frontend/src/pages/PromotionPage/components/list/PromotionCard/CardMeta/CardMeta.styles.ts b/frontend/src/pages/PromotionPage/components/list/PromotionCard/CardMeta/CardMeta.styles.ts index c45d224c1..62ad4d8be 100644 --- a/frontend/src/pages/PromotionPage/components/list/PromotionCard/CardMeta/CardMeta.styles.ts +++ b/frontend/src/pages/PromotionPage/components/list/PromotionCard/CardMeta/CardMeta.styles.ts @@ -41,21 +41,21 @@ export const MetaRow = styled.div` export const Icon = styled.div` width: 16px; height: 16px; - padding: 1.5px 0px; + margin: 2px 0px; display: flex; align-items: center; justify-content: center; - color: ${colors.gray[500]}; + flex-shrink: 0; - img { + svg { width: 100%; height: 100%; - object-fit: contain; } - ${media.mini_mobile} { + ${media.mobile} { width: 14px; height: 14px; + margin: 1.5px 0px; } `; diff --git a/frontend/src/pages/PromotionPage/components/list/PromotionCard/CardMeta/CardMeta.tsx b/frontend/src/pages/PromotionPage/components/list/PromotionCard/CardMeta/CardMeta.tsx index 4255ce663..b0c71ab58 100644 --- a/frontend/src/pages/PromotionPage/components/list/PromotionCard/CardMeta/CardMeta.tsx +++ b/frontend/src/pages/PromotionPage/components/list/PromotionCard/CardMeta/CardMeta.tsx @@ -1,5 +1,5 @@ -import LocationIcon from '@/assets/images/icons/location_icon.svg'; -import TimeIcon from '@/assets/images/icons/time_icon.svg'; +import LocationIcon from '@/assets/images/icons/location_icon.svg?react'; +import TimeIcon from '@/assets/images/icons/time_icon.svg?react'; import { formatKSTDate } from '@/utils/formatKSTDateTime'; import * as Styled from './CardMeta.styles'; @@ -20,7 +20,7 @@ const CardMeta = ({ title, location, startDate }: CardMetaProps) => { {location && ( - Location + {location} @@ -28,7 +28,7 @@ const CardMeta = ({ title, location, startDate }: CardMetaProps) => { - Time + {formattedStartDate} diff --git a/frontend/src/pages/WebviewMainPage/WebviewMainPage.tsx b/frontend/src/pages/WebviewMainPage/WebviewMainPage.tsx index 895cf7b1d..b136597e4 100644 --- a/frontend/src/pages/WebviewMainPage/WebviewMainPage.tsx +++ b/frontend/src/pages/WebviewMainPage/WebviewMainPage.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useMemo } from 'react'; import MobileMainIcon from '@/assets/images/logos/moadong_mobile_logo.svg'; import Filter from '@/components/common/Filter/Filter'; import Spinner from '@/components/common/Spinner/Spinner'; -import { PAGE_VIEW } from '@/constants/eventName'; +import { PAGE_NAME, PAGE_VIEW } from '@/constants/eventName'; import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; import { useGetCardList } from '@/hooks/Queries/useClub'; import usePromotionNotification from '@/hooks/Queries/usePromotionNotification'; @@ -48,8 +48,14 @@ const WebviewMainPage = () => { const clubList = useMemo(() => { if (!clubs.length) return null; - return clubs.map((club: Club) => ( - + return clubs.map((club: Club, i: number) => ( + diff --git a/frontend/src/styles/WebviewGlobal.styles.ts b/frontend/src/styles/WebviewGlobal.styles.ts index fb2a06509..60cf74eab 100644 --- a/frontend/src/styles/WebviewGlobal.styles.ts +++ b/frontend/src/styles/WebviewGlobal.styles.ts @@ -1,8 +1,13 @@ import { createGlobalStyle } from 'styled-components'; const WebviewGlobalStyles = createGlobalStyle` + html { + overflow-x: hidden; + } body { overscroll-behavior: none; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: 100%; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; diff --git a/frontend/src/utils/getDeviceType.ts b/frontend/src/utils/getDeviceType.ts new file mode 100644 index 000000000..bc15aec4e --- /dev/null +++ b/frontend/src/utils/getDeviceType.ts @@ -0,0 +1,15 @@ +import { BREAKPOINT } from '@/styles/mediaQuery'; + +type DeviceType = 'mini_mobile' | 'mobile' | 'tablet' | 'laptop' | 'desktop'; + +const getDeviceType = (): DeviceType => { + if (typeof window === 'undefined') return 'desktop'; + const width = window.innerWidth; + if (width <= BREAKPOINT.mini_mobile) return 'mini_mobile'; + if (width <= BREAKPOINT.mobile) return 'mobile'; + if (width <= BREAKPOINT.tablet) return 'tablet'; + if (width <= BREAKPOINT.laptop) return 'laptop'; + return 'desktop'; +}; + +export default getDeviceType; diff --git a/frontend/src/utils/webviewBridge.ts b/frontend/src/utils/webviewBridge.ts index 8af8415af..41ac1062b 100644 --- a/frontend/src/utils/webviewBridge.ts +++ b/frontend/src/utils/webviewBridge.ts @@ -44,7 +44,7 @@ declare global { const isDev = process.env.NODE_ENV === 'development'; -// 웹뷰 브릿지 코어 함수 — 앱 환경이 아니면 무시하고 false 반환 +// 웹뷰 브릿지 코어 함수 — 앱 환경이 아니거나 bridge가 없으면 false 반환 export const postMessageToApp = (message: WebViewMessage): boolean => { if (!isInAppWebView()) { if (isDev) { @@ -53,8 +53,18 @@ export const postMessageToApp = (message: WebViewMessage): boolean => { return false; } + if (!window.ReactNativeWebView) { + if (isDev) { + console.warn( + '[WebViewBridge] ReactNativeWebView 없음 (bridge 미주입):', + message.type, + ); + } + return false; + } + try { - window.ReactNativeWebView?.postMessage(JSON.stringify(message)); + window.ReactNativeWebView.postMessage(JSON.stringify(message)); if (isDev) { console.log('[WebViewBridge] 앱으로 전송:', message.type); }