From b3ce5aab8d73fe0fee023c8062dd05676ffc6df9 Mon Sep 17 00:00:00 2001 From: youngkwang choi Date: Tue, 5 May 2026 20:07:05 +0900 Subject: [PATCH 1/7] docs: add FSD architecture guide and API specifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로젝트 아키텍처(FSD)와 인증/투표 API 명세를 문서화합니다. 신규 개발자 온보딩 및 API 계약 공유를 위해 CLAUDE.md에 문서 링크를 추가합니다. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 6 + src/docs/api-spec/auth.md | 257 ++++++++++++++ src/docs/api-spec/vote.md | 645 +++++++++++++++++++++++++++++++++++ src/docs/architecture/fsd.md | 128 +++++++ 4 files changed, 1036 insertions(+) create mode 100644 src/docs/api-spec/auth.md create mode 100644 src/docs/api-spec/vote.md create mode 100644 src/docs/architecture/fsd.md diff --git a/CLAUDE.md b/CLAUDE.md index c21c629..f961cd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,12 @@ TanStack Router with file-based routing. Route files live in `src/pages/routes/` `src/main.tsx` → `src/app/App.tsx` wraps the app in: `Sentry.ErrorBoundary` → `HelmetProvider` → `QueryClientProvider` → `RouterProvider`. `PushNotificationBridge` and `Toast` (Sonner) are injected globally inside the router. +## Docs + +- [FSD Architecture Guide](src/docs/architecture/fsd.md) +- [Auth API](src/docs/api-spec/auth.md) +- [Vote API](src/docs/api-spec/vote.md) + ## Key conventions - **Package manager**: `pnpm` only diff --git a/src/docs/api-spec/auth.md b/src/docs/api-spec/auth.md new file mode 100644 index 0000000..94ad077 --- /dev/null +++ b/src/docs/api-spec/auth.md @@ -0,0 +1,257 @@ +# REST API — 유저 / 인증 + +## GET /api/users/me — 내 프로필 조회 + +### Request Header + +| Key | Value Type | Example | Optional? | Default | Description | +| --- | --- | --- | --- | --- | --- | +| Cookie | JWT | access_token=eyJhbGci...; HttpOnly; Path=/; Max-Age=1800; SameSite=Lax | Y | - | Access Token | + +### Response + +`200 OK` + +```json +{ + "email": "{{ String : 사용자 이메일 }}", + "nickname": "{{ String : 서비스 닉네임 }}", + "birthDate": "{{ LocalDate : 생년월일, yyyy형식 }}", + "gender": "{{ String : 성별, MALE 또는 FEMALE }}", + "imageColor": "{{ !String : 이미지 색상}}", + "userStatus": "{{ String : 사용자 가입 여부}}" +} +``` + +`401 Unauthorized` + +```json +{ + "code" : "EMPTY_TOKEN", + "message" : "인증 정보가 존재하지 않습니다." +} + +{ + "code" : "TOKEN_EXPIRED", + "message" : "인증 정보가 만료되었습니다." +} + +{ + "code" : "INVALID_TOKEN", + "message" : "유효하지 않은 토큰입니다." +} +``` + +`404 Not Found` + +```json +{ + "message": "존재하지 않는 사용자입니다." +} +``` + +## POST /api/users/me/profile — 추가 정보 저장 + +### Request Header + +| Key | Value Type | Example | Optional? | Default | Description | +| --- | --- | --- | --- | --- | --- | +| Cookie | JWT | access_token=eyJhbGci...; HttpOnly; Path=/; Max-Age=1800; SameSite=Lax | Y | - | Access Token | + +--- + +### Request Body + +```json +{ + "birthDate": "{{ LocalDate! : 생년월일, yyyy-MM-dd 형식 }}", + "gender": "{{ String! : 성별, MALE 또는 FEMALE }}", + "nickname": "{{ String! : 서비스에서 사용할 닉네임 }}", + "imageColor": "{{ String! : 사용자가 선택한 이미지 색상}}" +} +``` + +--- + +### Response + +`201 CREATED` + +```json +{ + "nickname" : "{{!String 사용자가 설정한 닉네임}}", + "imageColor" : " {{!String 사용자가 선택한 이미지 색상}}" +} +``` + +`401 Unauthorized` + +```json +{ + "message": "인증되지 않은 사용자", +} +``` + +`404 Not Found` + +```json +{ + "message": "사용자 정보 없음" +} +``` + +`500 Internal Server Error` + +```json +{ + "message": "서버 내부 오류", +} +``` + +## GET /api/user/nickname/suggest — 닉네임 추천 + +### Request Header + +| Key | Value Type | Example | Optional? | Default | Description | +| --- | --- | --- | --- | --- | --- | +| Cookie | JWT | access_token=eyJhbGci...; HttpOnly; Path=/; Max-Age=1800; SameSite=Lax | Y | - | Access Token | + +--- + +### Response + +`200 OK` + +```json +{ + "nickname" : "{{!String: 닉네임}}" +} +``` + +`401 Unauthorized` + +```json +{ + "message": "인증 정보가 존재하지 않습니다." +} +``` + +## POST /api/users/nickname/check — 닉네임 사용 가능 여부 확인 + +### Request Header + +| Key | Value Type | Example | Optional? | Default | Description | +| --- | --- | --- | --- | --- | --- | +| Cookie | JWT | access_token=eyJhbGci...; HttpOnly; Path=/; Max-Age=1800; SameSite=Lax | Y | - | Access Token | + +--- + +### Request Body + +```json +{ + "nickname": "{{ String! : 사용 가능 여부를 확인할 닉네임 }}" +} +``` + +--- + +### Response + +`400 Bad Request` + +```json +{ + "message": "닉네임 형식 오류" +} +``` + +`401 Unauthorized` + +```json +{ + "message": "인증 정보가 존재하지 않습니다.", +} +``` + +`500 Internal Server Error` + +```json +{ + "message": "서버 내부 오류", +} +``` + + +## POST /api/auth/reissue — Refresh Token을 검증하여 새로운 Access Token 발급 + +### Request Header + +| Key | Value Type | Example | Optional? | Default | Description | +| --- | --- | --- | --- | --- | --- | +| Cookie | | refresh_token=eyJhbGciOiJIUzUxMiJ9… | N | - | Access Token 재발급에 사용할 Refresh Token Cookie | + +--- + +### Response Header + +`200 OK` + +```json +{ + Set-Cookie: access_token={NEW_ACCESS_TOKEN}; HttpOnly; Path=/; Max-Age=3600 +} +``` + +`401 Unauthorized` + +```json +{ + "message": "인증 정보가 존재하지 않습니다." +} +``` + +`404 Not Found` + +```json +{ + "message": "존재하지 않은 사용자입니다." +} +``` + +`500 Internal Server Error` + +```json +{ + "message": "서버 내부 오류" +} +``` + + +## GET /api/users/info — 회원가입 과정에서 추가 정보 default 값 + +### Request Header + +| Key | Value Type | Example | Optional? | Default | Description | +| --- | --- | --- | --- | --- | --- | +| Cookie | | refresh_token=eyJhbGciOiJIUzUxMiJ9… | N | - | Access Token 재발급에 사용할 Refresh Token Cookie | + + +### Response + +`200 OK` + +```json +{ + "nickname" : {{!String : 닉네임}}, + "imageColor" : {{!String : 이미지 색}} +} +``` + +`401 Unauthorized` + +```json +{ + "message": "사용자 정보가 없습니다." +} +``` \ No newline at end of file diff --git a/src/docs/api-spec/vote.md b/src/docs/api-spec/vote.md new file mode 100644 index 0000000..a93c437 --- /dev/null +++ b/src/docs/api-spec/vote.md @@ -0,0 +1,645 @@ +# REST API — 일반형 투표 + +## GET /api/votes/{voteId} — 투표 상세 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Path | `voteId` | `Long` | ✅ | 투표 ID | + +### Response (회원 - 투표 전) + +```json +{ + "voteId": 1, + "title": "직장인 점심시간 혼밥 vs 같이 먹기", + "createdAt": "2026-04-14T13:49:00+09:00", + "content": "저는 혼자 밥 먹는 게 편한데 회사라서 막내라 혼자 밥 먹겠다고 하기 눈치보여요ㅠㅠ 혼밥하고 싶다고 말씀드려도 될까요?", + "thumbnailUrl": "https://cdn.example.com/votes/1/thumb.jpg", + "status": "ONGOING", + "endAt": "2026-04-14T23:59:00+09:00", + "participantCount": 31, + "options": [ + { "optionId": 10, "label": "혼밥이 편하다", "voteCount": null, "ratio": null }, + { "optionId": 11, "label": "그래도 밥은 같이 먹는 게 맞다", "voteCount": null, "ratio": null } + ], + "myVote": { + "voted": false, + "selectedOptionId": null + }, + "emojiSummary": { + "LIKE": 21, + "SAD": 3, + "ANGRY": 8, + "WOW": 36 + }, + "myEmoji": null, + "commentCount": 81 +} +``` + +### Response (회원 - 투표 후) + +```json +{ + "voteId": 1, + "title": "직장인 점심시간 혼밥 vs 같이 먹기", + "createdAt": "2026-04-14T13:49:00+09:00", + "content": "...", + "thumbnailUrl": "https://cdn.example.com/votes/1/thumb.jpg", + "status": "ONGOING", + "endAt": "2026-04-14T23:59:00+09:00", + "participantCount": 31, + "options": [ + { "optionId": 10, "label": "혼밥이 편하다", "voteCount": 22, "ratio": 70 }, + { "optionId": 11, "label": "그래도 밥은 같이 먹는 게 맞다", "voteCount": 9, "ratio": 30 } + ], + "myVote": { + "voted": true, + "selectedOptionId": 10 + }, + "emojiSummary": { + "LIKE": 21, + "SAD": 3, + "ANGRY": 8, + "WOW": 36 + }, + "myEmoji": "WOW", + "commentCount": 81 +} +``` + +> 💡 `myVote.voted = false`일 때는 `voteCount`/`ratio`를 `null`로 내려서 결과 비공개. 프론트는 옵션 버튼 형태로 노출. +`myVote.voted = true`일 때만 결과 표시. 다시투표하기 버튼은 프론트에서 `voted` 기준으로 분기. + +> `status`는 `ONGOING`, `ENDED` 두 개입니다. - `ONGOING` : 진행 중 (`now() < endAt`) - `ENDED` : 종료됨 (`now() ≥ endAt`) + +> 📌 비회원도 동일 응답. +단 `myVote.voted`는 비회원 무료 투표권으로 투표한 경우 `true`로 응답하며 +5회 소진 정책은 별도 API로 관리. `GET /api/me/free-votes` + +## POST /api/votes/{voteId}/participate — 투표 참여 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Path | `voteId` | `Long` | ✅ | 투표 ID | +| Body | `optionId` | `Long` | ✅ | 선택한 옵션 ID | + +### Request Body + +```json +{ "optionId": 10 } +``` + +### Response + +```json +{ + "voteId": 1, + "selectedOptionId": 10, + "options": [ + { "optionId": 10, "label": "혼밥이 편하다", "voteCount": 22, "ratio": 70 }, + { "optionId": 11, "label": "그래도 밥은 같이 먹는 게 맞다", "voteCount": 9, "ratio": 30 } + ], + "participantCount": 31, + "remainingFreeVotes": 4 +} +``` + +> 💡 `remainingFreeVotes`는 비회원에게만 의미 있는 값. 회원은 `null` +> + +> 📌 비회원이 5회 소진 후 추가 시도 시 `403 VOTE_FREE_LIMIT_EXCEEDED` 응답 +> + +## DELETE /api/votes/{voteId}/participate — 다시투표하기 (투표 취소) + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Path | `voteId` | `Long` | ✅ | 투표 ID | + +### Response + +`204 No Content` + +> 💡 투표 결과 화면에서 `다시투표하기` 클릭 시 호출. +호출 후 프론트는 `GET /api/votes/{voteId}` 재조회하여 옵션 선택 상태로 복귀. +> + +## PUT /api/votes/{voteId}/emoji — 이모지 반응 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Path | `voteId` | `Long` | ✅ | 투표 ID | +| Body | `emoji` | `LIKE | SAD | ANGRY | WOW | null` | ✅ | 선택한 이모지. `null`이면 선택 취소 | + +### Request Body + +```json +{ "emoji": "WOW" } +``` + +### Response + +```json +{ + "emojiSummary": { + "LIKE": 21, + "SAD": 3, + "ANGRY": 8, + "WOW": 37 + "total": 132 + }, + "myEmoji": "WOW" +} +``` + +> 💡 이모지는 1인 1개만 가능. 다른 이모지 선택 시 기존 반응 자동 교체. +같은 이모지 재선택 또는 `emoji: null` 전송 시 반응 취소. + +> 📌 회원/비회원 모두 호출 가능. 비회원은 쿠키 기반 식별. + +--- + +# REST API — 투표 결과 (마감 후) + +## GET /api/votes/{voteId}/result — 투표 결과 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Path | `voteId` | `Long` | ✅ | 투표 ID | + +### Response (회원 - 참여O) + +```json +{ + "voteId": 1, + "title": "직장인 점심시간 혼밥 vs 같이 먹기", + "createdAt": "2026-04-14T13:49:00+09:00", + "content": "저는 혼자 밥 먹는 게 편한데 회사라서 막내라 혼자 밥 먹겠다고 하기 눈치보여요ㅠㅠ...", + "thumbnailUrl": "https://cdn.example.com/votes/1/thumb.jpg", + "status": "ENDED", + "endAt": "2026-04-14T23:59:00+09:00", + "participantCount": 520, + "result": { + "options": [ + { "optionId": 10, "label": "혼밥이 편하다", "voteCount": 364, "ratio": 70 }, + { "optionId": 11, "label": "그래도 밥은 같이 먹는 게 맞다", "voteCount": 156, "ratio": 30 } + ] + }, + "myVote": { + "voted": true, + "selectedOptionId": 11 + }, + "insight": { + "locked": false, + "scope": "MY_SELECTION", + "selectionCount": 156, + "genderDistribution": { + "female": { "count": 96, "ratio": 62 }, + "male": { "count": 60, "ratio": 38 } + }, + "ageDistribution": [ + { "ageGroup": "20s", "ratio": 28, "isMyGroup": true }, + { "ageGroup": "30s", "ratio": 52, "isMyGroup": false }, + { "ageGroup": "40s", "ratio": 20, "isMyGroup": false } + ] + }, + "aiInsight": { + "available": true, + "headline": "20대 여성 그룹에서 \"같이 밥먹기\"를 선택한 비율이 71%로 가장 높게 나타났어요.", + "body": "MZ 세대를 중심으로 혼밥 문화가 확산되는 트렌드가 반영된 결과예요." + } +} +``` + +### Response (회원 - 참여X) + +```json +{ + "voteId": 1, + "title": "직장인 점심시간 혼밥 vs 같이 먹기", + "createdAt": "2026-04-14T13:49:00+09:00", + "content": "...", + "thumbnailUrl": "https://cdn.example.com/votes/1/thumb.jpg", + "status": "ENDED", + "endAt": "2026-04-14T23:59:00+09:00", + "participantCount": 520, + "result": { + "options": [ + { "optionId": 10, "label": "혼밥이 편하다", "voteCount": 364, "ratio": 70 }, + { "optionId": 11, "label": "그래도 밥은 같이 먹는 게 맞다", "voteCount": 156, "ratio": 30 } + ] + }, + "myVote": { + "voted": false, + "selectedOptionId": null + }, + "insight": { + "locked": false, + "scope": "TOTAL", + "selectionCount": 364, + "genderDistribution": { + "female": { "count": 225, "ratio": 62 }, + "male": { "count": 139, "ratio": 38 } + }, + "ageDistribution": [ + { "ageGroup": "20s", "ratio": 28, "isMyGroup": false }, + { "ageGroup": "30s", "ratio": 52, "isMyGroup": false }, + { "ageGroup": "40s", "ratio": 20, "isMyGroup": false } + ] + }, + "aiInsight": { + "available": false, + "headline": null, + "body": null + } +} +``` + +### Response (비회원) + +```json +{ + "voteId": 1, + "title": "직장인 점심시간 혼밥 vs 같이 먹기", + "createdAt": "2026-04-14T13:49:00+09:00", + "content": "...", + "thumbnailUrl": "https://cdn.example.com/votes/1/thumb.jpg", + "status": "ENDED", + "endAt": "2026-04-14T23:59:00+09:00", + "participantCount": 520, + "result": { + "options": [ + { "optionId": 10, "label": "혼밥이 편하다", "voteCount": 364, "ratio": 70 }, + { "optionId": 11, "label": "그래도 밥은 같이 먹는 게 맞다", "voteCount": 156, "ratio": 30 } + ] + }, + "myVote": { + "voted": false, + "selectedOptionId": null + }, + "insight": { + "locked": true, + "scope": null, + "selectionCount": null, + "genderDistribution": null, + "ageDistribution": null + }, + "aiInsight": { + "available": false, + "headline": null, + "body": null + } +} +``` + +> 💡 `insight.scope` 분기 규칙 — +`MY_SELECTION`: 회원 + 참여O → 본인이 선택한 옵션 기준 분석, 본인 연령대(`isMyGroup: true`) 강조. +`TOTAL`: 회원 + 참여X → 전체 참여자 기준 분석, 다수 선택 옵션을 컬러 강조 (프론트가 `result.options` 비교해서 처리). +`null` + `locked: true`: 비회원 → 잠금 컴포넌트 노출. + +> 📌 `aiInsight.available`은 회원+참여O일 때만 `true` 가능 + +> 💡 `status: ONGOING`인 voteId로 호출 시 `403 VOTE_NOT_ENDED` 응답. +진행 중 투표는 `GET /api/votes/{voteId}`로만 조회. + +## GET /api/votes/{voteId}/share — 공유 링크 생성 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Path | `voteId` | `Long` | ✅ | 투표 ID | + +### Response + +```json +{ + "shareUrl": "https://vs.app/poll/result/12345", + "title": "직장인 점심시간 혼밥 vs 같이 먹기", + "thumbnailUrl": "https://cdn.example.com/votes/1/thumb.jpg" +} +``` + +> 💡 공유하기 버튼 → 팝업 노출 시 호출. 프론트는 `shareUrl`을 클립보드 복사 후 토스트 노출. +공유 링크로 진입한 비회원도 `GET /api/votes/{voteId}/result` 호출하여 잠금 상태로 결과 확인 가능. + +--- + +# REST API — 알림 + +## GET /api/notifications — 알림 목록 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Query | `cursor` | `Long` | ❌ | 이전 페이지 마지막 notificationId | +| Query | `size` | `Int` | ❌ | 페이지 크기 (기본 20) | + +### Response + +```json +{ + "notifications": [ + { + "notificationId": 9001, + "type": "VOTE_ENDED", + "voteId": 1, + "title": "투표 결과가 공개됐어요", + "body": "[직장인 점심, 혼밥 VS 같이먹기] 결과 보러가기", + "isRead": false, + "createdAt": "2026-04-14T17:00:00+09:00" + } + ], + "nextCursor": 8980, + "hasNext": true +} +``` + +> 📌 알림 리스트 항목 클릭 시 프론트는 `voteId` 기반으로 결과 화면(`/api/votes/{voteId}/result`) 진입. + +## POST /api/notifications/{notificationId}/read — 알림 읽음 처리 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Path | `notificationId` | `Long` | ✅ | 알림 ID | + +### Response + +`204 No Content` + +## POST /api/devices/push-token — PUSH 토큰 등록 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Body | `token` | `String` | ✅ | FCM/APNs 디바이스 토큰 | +| Body | `platform` | `IOS | ANDROID` | ✅ | 플랫폼 | + +### Request Body + +```json +{ + "token": "fcm_xxxxxxxxxxxxxxxxxxxx", + "platform": "IOS" +} +``` + +### Response + +`204 No Content` + +> 💡 투표 종료 시 PUSH 알림(`투표 결과가 공개됐어요`) 발송 대상 식별용. 회원이 투표 참여한 경우에만 발송. + +--- + +# REST API — 몰입형 투표 + +## GET /api/immersive-votes — 몰입형 투표 피드 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Query | `cursor` | `Long` | ❌ | 이전 페이지 마지막 voteId. 없으면 최신부터 | +| Query | `size` | `Int` | ❌ | 페이지 크기 (기본값: 10) | + +### Response + +```json +{ + "votes": [ + { + "voteId": 1, + "title": "논쟁 끝판왕 밸런스게임", + "content": "자기 전에 갑자기 생각난 밸런스 게임인데 한 번 골라봐. 친구들한테 물어봤는데도 의견이 엄청 갈리더라...", + "imageUrl": "https://cdn.example.com/votes/1/main.jpg", + "endAt": "2026-04-27T23:59:00+09:00", + "options": [ + { "optionId": 10, "label": "스윙칩만 3달 먹기", "voteCount": null, "ratio": null }, + { "optionId": 11, "label": "스윙스한테 30만원 주기", "voteCount": null, "ratio": null } + ], + "myVote": { + "voted": false, + "selectedOptionId": null + }, + "emojiSummary": { + "LIKE": 21, + "SAD": 3, + "ANGRY": 8, + "WOW": 36, + "total": 131 + }, + "myEmoji": null, + "commentCount": 27, + "currentViewerCount": 13 + } + ], + "nextCursor": 980, + "hasNext": true +} +``` + +> 📌 위/아래 스와이프로 다음 투표 이동 → 프론트는 `cursor` 기반 prefetch 권장 (현재 인덱스 기준 ±2개 미리 로딩). +회원/비회원 동일 응답. 단 비회원이 5회 소진 후에도 피드 자체는 계속 조회 가능. + +## POST /api/immersive-votes/{voteId}/participate — 투표 참여 / 취소 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Path | `voteId` | `Long` | ✅ | 투표 ID | +| Body | `optionId` | `Long` | ✅ | 선택할 옵션 ID. 이미 같은 옵션을 선택한 상태면 취소 처리 | + +### Request Body + +```json +{ "optionId": 10 } +``` + +### Response (참여 / 변경) + +```json +{ + "voteId": 1, + "action": "VOTED", + "selectedOptionId": 10, + "options": [ + { "optionId": 10, "label": "스윙칩만 3달 먹기", "voteCount": 99, "ratio": 76 }, + { "optionId": 11, "label": "스윙스한테 30만원 주기", "voteCount": 32, "ratio": 24 } + ], + "remainingFreeVotes": 2 +} +``` + +### Response (취소 — 같은 옵션 재클릭) + +```json +{ + "voteId": 1, + "action": "CANCELED", + "selectedOptionId": null, + "options": [ + { "optionId": 10, "label": "스윙칩만 3달 먹기", "voteCount": null, "ratio": null }, + { "optionId": 11, "label": "스윙스한테 30만원 주기", "voteCount": null, "ratio": null } + ], + "remainingFreeVotes": 2 +} +``` + +> 💡 `action` 분기 +`VOTED`: 신규 투표 또는 다른 옵션으로 변경 (기존 선택 자동 해제 후 신규 카운트). +`CANCELED`: 같은 옵션 재클릭 → 투표 취소 → `voteCount`/`ratio` 다시 `null`로 응답. + +> 📌 비회원 무료 투표 차감 정책 +신규 투표(`VOTED` + 기존 미참여) → 차감. +옵션 변경(`VOTED` + 기존 참여) → 차감하지 않음 (재선택은 무료). +취소(`CANCELED`) → 차감하지 않음, 단 재참여 시 다시 차감. +`remainingFreeVotes === 0` 상태에서 신규 투표 시도 → `403 VOTE_FREE_LIMIT_EXCEEDED` → 로그인 유도 팝업. `remainingFreeVotes` 1~5일 때 응답 후 프론트에서 "n회 남았어요" 토스트. + +> 💡 회원은 `remainingFreeVotes: null`로 응답. 투표 횟수 제한 없음. + +## GET /api/immersive-votes/{voteId}/live — 실시간 비율 폴링 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Path | `voteId` | `Long` | ✅ | 투표 ID | + +### Response + +```json +{ + "options": [ + { "optionId": 10, "voteCount": 102, "ratio": 78 }, + { "optionId": 11, "voteCount": 29, "ratio": 22 } + ], + "currentViewerCount": 14, + "totalParticipantCount": 131 +} +``` + +> 💡 투표 후(`myVote.voted: true`)에만 호출. 옵션 비율 filled bar 애니메이션 갱신용. + +> 📌 `currentViewerCount`: 현재 해당 투표 화면을 보고 있는 사용자 수. +10명 이상일 때 2분마다 5초간 토스트("현재 N명이 참여중이에요!") 노출 + +## PUT /api/immersive-votes/{voteId}/emoji — 이모지 반응 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Path | `voteId` | `Long` | ✅ | 투표 ID | +| Body | `emoji` | `LIKE | SAD | ANGRY | WOW | null` | ✅ | 선택한 이모지. `null`이면 취소 | + +### Request Body + +```json +{ "emoji": "WOW" } +``` + +### Response + +```json +{ + "emojiSummary": { + "LIKE": 21, + "SAD": 3, + "ANGRY": 8, + "WOW": 37, + "total": 132 + }, + "myEmoji": "WOW" +} +``` + +> 💡 이모지는 1인 1개만 가능. 다른 이모지 선택 시 기존 자동 해제 후 신규 카운트. +같은 이모지 재클릭 또는 `emoji: null` → 선택 취소. 회원/비회원 모두 호출 가능. + +> 📌 플로팅 애니메이션은 프론트 처리 (선택 즉시 화면 하단→상단 이동, 3초 후 자동 사라짐). 백엔드는 카운트만 갱신. + +## GET /api/immersive-votes/{voteId}/share — 공유 링크 생성 + +| **구분** | **파라미터** | **타입** | **필수** | **설명** | +| --- | --- | --- | --- | --- | +| Path | `voteId` | `Long` | ✅ | 투표 ID | + +### Response + +```json +{ + "shareUrl": "https://vs.app/poll/12345", + "title": "논쟁 끝판왕 밸런스게임", + "thumbnailUrl": "https://cdn.example.com/votes/1/thumb.jpg" +} +``` + +## GET /api/me/free-votes — 비회원 잔여 무료 투표권 (전역) + +### Response + +```json +{ + "remainingFreeVotes": 2, + "totalFreeVotes": 5 +} +``` + +> 💡 비회원의 무료 투표권은 voteId별이 아닌 전역 카운트로 관리. 앱 진입 시 1회 호출하여 잔여 횟수 캐싱. 이후는 `participate` 응답값으로 동기화. 회원이 호출 시 `remainingFreeVotes: null` 응답. + +--- + +# WebSocket (STOMP) + +> 🔌 연결 엔드포인트: `ws://.../ws` + +| **구분** | **경로** | **방향** | **설명** | +| --- | --- | --- | --- | +| 몰입형 실시간 비율 | `/topic/immersive-vote/{voteId}/live` | 수신 ← 서버 | 비율/뷰어 수 변동 시 푸시 | + +### 수신 Payload + +`/topic/immersive-vote/{voteId}/live` + +```json +{ + "options": [ + { "optionId": 10, "voteCount": 102, "ratio": 78 }, + { "optionId": 11, "voteCount": 29, "ratio": 22 } + ], + "currentViewerCount": 14, + "totalParticipantCount": 131 +} +``` + +--- + +# 권한 정책 + +| **상황** | **처리** | +| --- | --- | +| 비회원 → 투표 상세 조회 | 허용 | +| 비회원 → 투표 참여 (1~5회, 신규) | 허용. 매 회 차감 후 응답에 잔여 횟수 포함 | +| 비회원 → 옵션 변경/취소 | 허용. 차감하지 않음 | +| 비회원 → 투표 참여 (5회 소진 후 신규) | `403 VOTE_FREE_LIMIT_EXCEEDED` → 로그인 유도 팝업 | +| 비회원 → 이모지 반응 | 허용 | +| 비회원 → 공유 | 허용 | +| 비회원 → 결과 조회 | 허용 (`insight.locked: true`로 응답) | +| 비회원 → 잠금 해제 | 로그인 페이지 랜딩 (프론트 처리). 로그인 후 재진입 시 `insight.locked: false` | +| 비회원 → 알림 목록/PUSH 토큰 | `401` | +| 비회원 → 채팅 진입 | 채팅 명세 권한 정책 참조 | +| 회원 → 미참여 결과 조회 | 허용 (`scope: TOTAL`) | +| 회원 → 참여O 결과 조회 | 허용 (`scope: MY_SELECTION`) | +| 회원 → 투표 후 다시투표하기 | 허용. ENDED 상태에서는 `403` | +| 진행 중 투표 → `/result` 호출 | `403 VOTE_NOT_ENDED` | +| 투표 종료(ENDED) → 참여/취소 | `403 VOTE_ENDED` | + +--- + +# 에러 코드 + +| **코드** | **HTTP** | **설명** | +| --- | --- | --- | +| `VOTE_NOT_FOUND` | 404 | 존재하지 않는 투표 | +| `VOTE_ENDED` | 403 | 종료된 투표에 대한 참여/취소 시도 | +| `VOTE_NOT_ENDED` | 403 | 진행 중 투표에 결과 API 호출 | +| `VOTE_FREE_LIMIT_EXCEEDED` | 403 | 비회원 무료 투표 5회 초과 | +| `INVALID_OPTION` | 400 | 해당 투표에 속하지 않은 optionId | +| `INVALID_EMOJI` | 400 | 정의되지 않은 이모지 타입 | +| `IMAGE_LOAD_FAILED` | - | 백엔드 에러 아님. 프론트가 placeholder 노출 | +| `VOTE_SUBMIT_FAILED` | 500 | "투표에 실패했어요" 토스트 (2초) | +| `EMOJI_SUBMIT_FAILED` | 500 | "이모지 반응에 실패했어요" 토스트 (2초) | +| `SHARE_LINK_GENERATION_FAILED` | 500 | "공유에 실패했어요" 토스트 (2초) | +| `AI_INSIGHT_GENERATION_FAILED` | - | 에러 아님. `aiInsight.available: false`로 응답 | +| `NOTIFICATION_NOT_FOUND` | 404 | 존재하지 않는 알림 | \ No newline at end of file diff --git a/src/docs/architecture/fsd.md b/src/docs/architecture/fsd.md new file mode 100644 index 0000000..419af9b --- /dev/null +++ b/src/docs/architecture/fsd.md @@ -0,0 +1,128 @@ +# Feature-Sliced Design (FSD) 아키텍처 가이드 + +## 팀 컨벤션 + +1. `Layer`와 `Segment` 이름은 변형하거나 축약하지 않고 그대로 사용한다. +2. 경계가 애매하면 적용하지 않는 것이 낫다. +3. 초반에는 `pages/ui`, `pages/model`, `pages/api` 조합으로 시작하고, 도메인 패턴이 반복되면 `features`로 분리한다. +4. `base`는 타 프로젝트에서도 재사용 가능한 경우에만 사용한다. 프로젝트 전용이면 `app`에 둔다. + +--- + +## Layer 구조 + +레이어는 아래 순서대로 의존한다. 상위 레이어는 하위 레이어를 import할 수 있지만, 반대 방향은 금지한다. + +``` +app → pages → layouts → features → base +``` + +### `app` + +> 프로젝트 내부에서만 사용되는 전역 설정 및 공통 컴포넌트 + +| Segment | 용도 | 예시 | +|---------|------|------| +| `ui` | 전역 레이아웃 / 테마 UI | `AppLayout` | +| `api` | 프로젝트 전용 초기 API 설정 | `initApiClient` | +| `model` | 전역 상태 관리 훅, 공통 타입 | `useAppState` | +| `lib` | 프로젝트 전용 유틸리티 | `initializeApp` | +| `config` | 전역 상수 및 환경 설정 | `APP_NAME`, `DEFAULT_LANGUAGE` | + +### `base` (= shared) + +> 여러 프로젝트에서 재사용 가능한 범용 컴포넌트 및 유틸리티 + +| Segment | 용도 | 예시 | +|---------|------|------| +| `ui` | 범용 재사용 UI 컴포넌트 | `Button`, `Modal`, `FormField` | +| `api` | 공통 API 설정 및 유틸리티 | Axios 인스턴스 설정 | +| `model` | 공통 기능 훅 | `useAuth` | +| `lib` | 도메인 무관 순수 유틸리티 함수 | `formatDate`, `deepClone` | +| `config` | 전역 상수 | 반응형 브레이크포인트, 색상 토큰 | + +### `features` (entities 개념 포함) + +> 특정 사용자 행동 및 비즈니스 로직 단위 + +| Segment | 용도 | 예시 | +|---------|------|------| +| `ui` | 주요 행동을 구현하는 컴포넌트 | `AddToCartButton` | +| `api` | 기능별 API 호출 (TanStack Query) | `useAddToCart` | +| `model` | 비즈니스 로직 커스텀 훅 | `useCartState` | +| `lib` | 기능 관련 유틸리티 | — | +| `config` | 기능별 상수 및 설정값 | — | + +### `layouts` (= widgets) + +> 여러 기능/UI 요소를 조합한 독립적인 복합 컴포넌트 + +| Segment | 용도 | 예시 | +|---------|------|------| +| `ui` | UI 요소 조합 컴포넌트 | `Header`, `Footer`, `DashboardWidget` | +| `api` | 위젯 전용 데이터 페칭 | `fetchWidgetData` | +| `model` | 데이터 집계 로직 (드물게 사용) | — | +| `lib` | 위젯 전용 유틸리티 | — | +| `config` | 위젯 동작 관련 설정 | — | + +### `pages` + +> 라우트 단위 페이지 레이아웃 및 로직 + +| Segment | 용도 | 예시 | +|---------|------|------| +| `ui` | 페이지 레이아웃 및 콘텐츠 구조 | `MainPage`, `ServicePage` | +| `api` | 페이지 전용 API 호출 | `useFetchHomePageData` | +| `model` | 라우트 처리 및 페이지별 데이터 타입 | — | +| `lib` | 페이지 동작 유틸리티 | — | +| `config` | 페이지 관련 설정값 | — | + +--- + +## Segment 정의 + +### `api` + +서버와의 모든 통신 관련 코드. + +- 단일 리소스 CRUD API 호출 +- 여러 엔드포인트를 조합한 복합 API 요청 +- 캐싱 및 낙관적 업데이트를 포함한 서버 상태 동기화 +- 다단계 API 흐름 및 에러 처리 + +### `ui` + +`.tsx` 파일만 포함. 시각적 표현 담당. + +- 스타일과 레이아웃만 담당하는 순수 UI 컴포넌트 +- 도메인 데이터를 표시하는 읽기 전용 컴포넌트 +- 상태 관리 및 사용자 액션을 처리하는 인터랙티브 컴포넌트 +- 여러 컴포넌트를 조합한 독립적 UI 블록 +- 페이지와 레이아웃을 구성하는 최상위 컴포넌트 + +### `model` + +비즈니스 로직이 포함된 코드. + +- 도메인 타입 및 인터페이스 정의 +- 순수한 도메인 계산 및 변환 함수 +- 상태 관리 및 사이드이펙트를 처리하는 커스텀 훅 +- 복잡한 비즈니스 규칙 및 다단계 도메인 로직 + +### `lib` + +비즈니스 도메인에 종속되지 않는 유틸리티 함수. + +- 기본 데이터 타입 처리 순수 함수 (예: `formatDate`) +- 도메인 특화 데이터 처리 헬퍼 함수 +- 유효성 검사 및 에러 처리 로직 +- 다단계 데이터 처리 파이프라인 유틸리티 + +### `config` + +동작 없이 값만 선언하는 상수 모음. + +- 기본 상수값 및 열거형(enum) 정의 +- 환경별 설정값 및 환경 변수 +- 도메인별 규칙 및 제약 조건 +- 조건부/동적 설정 포함 고급 설정 관리 From 98cbe246935f9f43daa167c7ea627563d53f058b Mon Sep 17 00:00:00 2001 From: youngkwang choi Date: Tue, 5 May 2026 20:07:18 +0900 Subject: [PATCH 2/7] feat: add base API client, Modal, Spinner components and global animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 여러 feature에서 재사용 가능한 기반 레이어를 구성합니다. - base/api: Axios 인스턴스(credentials, JSON 헤더 기본값) - base/ui/Modal: 포탈 기반 모달(포커스 트랩, 스크롤 락, 진입/퇴출 애니메이션) - base/ui/Spinner: 로딩 상태 표시 컴포넌트 - global CSS: Modal 전용 keyframe 애니메이션 추가 - lock.svg: 잠금 콘텐츠 UI용 아이콘 Co-Authored-By: Claude Sonnet 4.6 --- public/assets/icons/lock.svg | 5 ++ src/app/styles/index.css | 32 ++++++++ src/base/api/client.ts | 7 ++ src/base/ui/Modal/index.tsx | 148 ++++++++++++++++++++++++++++++++++ src/base/ui/Spinner/index.tsx | 7 ++ 5 files changed, 199 insertions(+) create mode 100644 public/assets/icons/lock.svg create mode 100644 src/base/api/client.ts create mode 100644 src/base/ui/Modal/index.tsx create mode 100644 src/base/ui/Spinner/index.tsx diff --git a/public/assets/icons/lock.svg b/public/assets/icons/lock.svg new file mode 100644 index 0000000..8310cc8 --- /dev/null +++ b/public/assets/icons/lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/styles/index.css b/src/app/styles/index.css index 9ee0550..c90cf9e 100644 --- a/src/app/styles/index.css +++ b/src/app/styles/index.css @@ -127,6 +127,38 @@ } } +@keyframes modal-backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modal-backdrop-out { + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes modal-panel-in { + from { + opacity: 0; + transform: translateY(12px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes modal-panel-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(8px) scale(0.97); + } +} + @keyframes shimmer { from { background-position: 200% 0; diff --git a/src/base/api/client.ts b/src/base/api/client.ts new file mode 100644 index 0000000..dff9617 --- /dev/null +++ b/src/base/api/client.ts @@ -0,0 +1,7 @@ +import axios from "axios"; + +export const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL, + headers: { "Content-Type": "application/json" }, + withCredentials: true, +}); diff --git a/src/base/ui/Modal/index.tsx b/src/base/ui/Modal/index.tsx new file mode 100644 index 0000000..e060415 --- /dev/null +++ b/src/base/ui/Modal/index.tsx @@ -0,0 +1,148 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +const EASING = "cubic-bezier(0.22, 1, 0.36, 1)"; +const DURATION_IN = 220; +const DURATION_OUT = 180; + +const FOCUSABLE_SELECTOR = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + className?: string; +} + +function Modal({ isOpen, onClose, children, className }: ModalProps) { + const [mounted, setMounted] = useState(false); + const [isClosing, setIsClosing] = useState(false); + + const overlayRef = useRef(null); + const panelRef = useRef(null); + const previousFocusRef = useRef(null); + const hasOpenedRef = useRef(false); + + // Mount/unmount with animation + useEffect(() => { + if (isOpen) { + hasOpenedRef.current = true; + previousFocusRef.current = document.activeElement as HTMLElement; + setIsClosing(false); + setMounted(true); + return; + } + if (!hasOpenedRef.current) return; + setIsClosing(true); + const t = setTimeout(() => { + setMounted(false); + setIsClosing(false); + previousFocusRef.current?.focus(); + }, DURATION_OUT); + return () => clearTimeout(t); + }, [isOpen]); + + // Scroll lock — iOS Safari needs position:fixed + stored scrollY + useEffect(() => { + if (!mounted) return; + const scrollY = window.scrollY; + const { style } = document.body; + style.overflow = "hidden"; + style.position = "fixed"; + style.top = `-${scrollY}px`; + style.width = "100%"; + return () => { + style.overflow = ""; + style.position = ""; + style.top = ""; + style.width = ""; + window.scrollTo(0, scrollY); + }; + }, [mounted]); + + // Focus initial element when panel mounts + useEffect(() => { + if (!mounted || !panelRef.current) return; + const first = panelRef.current.querySelector(FOCUSABLE_SELECTOR); + (first ?? panelRef.current).focus(); + }, [mounted]); + + // Focus trap + Escape + useEffect(() => { + if (!mounted) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab" || !panelRef.current) return; + + const focusables = Array.from(panelRef.current.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => el.offsetParent !== null, + ); + + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + + if (!first || !last) { + e.preventDefault(); + return; + } + + if (e.shiftKey) { + if (document.activeElement === first || !panelRef.current.contains(document.activeElement)) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last || !panelRef.current.contains(document.activeElement)) { + e.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [mounted, onClose]); + + if (!mounted) return null; + + return createPortal( +
{ + if (e.target === overlayRef.current) onClose(); + }} + > + e.stopPropagation()} + > + {children} + +
, + document.body, + ); +} + +export { Modal }; diff --git a/src/base/ui/Spinner/index.tsx b/src/base/ui/Spinner/index.tsx new file mode 100644 index 0000000..623b13c --- /dev/null +++ b/src/base/ui/Spinner/index.tsx @@ -0,0 +1,7 @@ +export function Spinner({ className }: { className?: string }) { + return ( +
+ ); +} From 028cbacc737f4fef23d7749059ed2060abfa99e2 Mon Sep 17 00:00:00 2001 From: youngkwang choi Date: Tue, 5 May 2026 20:07:27 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20add=20auth=20feature=20=E2=80=94=20?= =?UTF-8?q?User=20type=20definition=20and=20current=20user=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 투표 결과 인사이트(성별/연령대 분포)에서 로그인 사용자 정보를 활용하기 위해 인증 기능의 기반 계층을 구성합니다. - model/types.ts: User 인터페이스(닉네임, 성별, 생년월일, 상태 등) - api/userQuery.ts: GET /api/users/me TanStack Query 훅 Co-Authored-By: Claude Sonnet 4.6 --- src/features/auth/api/userQuery.ts | 32 ++++++++++++++++++++++++++++++ src/features/auth/model/types.ts | 8 ++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/features/auth/api/userQuery.ts create mode 100644 src/features/auth/model/types.ts diff --git a/src/features/auth/api/userQuery.ts b/src/features/auth/api/userQuery.ts new file mode 100644 index 0000000..da7c906 --- /dev/null +++ b/src/features/auth/api/userQuery.ts @@ -0,0 +1,32 @@ +import { apiClient } from "@base/api/client"; +import { queryOptions } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import type { User } from "../model/types"; +// MOCK_START — 제거 시 이 줄부터 MOCK_END까지와 mockUser import를 삭제 +const mockUser: User = { + email: "test@example.com", + nickname: "테스트유저", + birthDate: "1998-03-15", + gender: "FEMALE", + imageColor: "#9A9AF6", + userStatus: "ACTIVE", +}; +// MOCK_END + +export const userQueryOptions = () => + queryOptions({ + queryKey: ["user", "me"], + // MOCK_START — 제거 시 이 줄부터 MOCK_END까지 삭제 후 아래 실제 호출 주석 해제 + queryFn: () => Promise.resolve(null), + // queryFn: async () => { + // try { + // const r = await apiClient.get("/api/users/me"); + // return r.data; + // } catch (err) { + // if (isAxiosError(err) && err.response?.status === 401) return null; + // throw err; + // } + // }, + // MOCK_END + staleTime: 1000 * 60 * 5, + }); diff --git a/src/features/auth/model/types.ts b/src/features/auth/model/types.ts new file mode 100644 index 0000000..d5f205d --- /dev/null +++ b/src/features/auth/model/types.ts @@ -0,0 +1,8 @@ +export interface User { + email: string; + nickname: string; + birthDate: string; + gender: "MALE" | "FEMALE"; + imageColor: string; + userStatus: string; +} From 196ce557ef5d1f1fa228f9060e55c3b1accd7701 Mon Sep 17 00:00:00 2001 From: youngkwang choi Date: Tue, 5 May 2026 20:07:47 +0900 Subject: [PATCH 4/7] feat: add vote detail model, API queries, and mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 투표 상세 페이지에 필요한 데이터 계층 전체를 구성합니다. 타입: - model/types.ts: VoteDetail, VoteOption, EmojiType 등 도메인 타입 - model/resultTypes.ts: VoteResult, InsightUnlocked/InsightLocked 타입 비즈니스 로직: - model/useVoteDetail.ts: 상세 조회 + 참여/취소/이모지 뮤테이션 통합 훅 - model/voteDetailUtils.ts: 남은 시간 계산, 옵션 정렬 등 순수 유틸 API: - api/voteDetailQuery.ts: GET /api/votes/{voteId} - api/voteResultQuery.ts: GET /api/votes/{voteId}/result - api/voteParticipate.ts: POST/DELETE 투표 참여·취소 - api/voteEmoji.ts: PUT 이모지 반응 - api/mock*.ts: 개발용 목업 데이터 Co-Authored-By: Claude Sonnet 4.6 --- src/features/votes/api/mockVoteDetail.ts | 24 +++ src/features/votes/api/mockVoteResult.ts | 39 ++++ src/features/votes/api/voteDetailQuery.ts | 15 ++ src/features/votes/api/voteEmoji.ts | 17 ++ src/features/votes/api/voteParticipate.ts | 31 +++ src/features/votes/api/voteResultQuery.ts | 15 ++ src/features/votes/model/resultTypes.ts | 40 ++++ src/features/votes/model/types.ts | 53 +++++ src/features/votes/model/useVoteDetail.ts | 212 ++++++++++++++++++++ src/features/votes/model/voteDetailUtils.ts | 10 + 10 files changed, 456 insertions(+) create mode 100644 src/features/votes/api/mockVoteDetail.ts create mode 100644 src/features/votes/api/mockVoteResult.ts create mode 100644 src/features/votes/api/voteDetailQuery.ts create mode 100644 src/features/votes/api/voteEmoji.ts create mode 100644 src/features/votes/api/voteParticipate.ts create mode 100644 src/features/votes/api/voteResultQuery.ts create mode 100644 src/features/votes/model/resultTypes.ts create mode 100644 src/features/votes/model/types.ts create mode 100644 src/features/votes/model/useVoteDetail.ts create mode 100644 src/features/votes/model/voteDetailUtils.ts diff --git a/src/features/votes/api/mockVoteDetail.ts b/src/features/votes/api/mockVoteDetail.ts new file mode 100644 index 0000000..a4beb32 --- /dev/null +++ b/src/features/votes/api/mockVoteDetail.ts @@ -0,0 +1,24 @@ +import type { VoteDetail } from "../model/types"; + +export const mockVoteDetail: VoteDetail = { + voteId: 1, + title: "직장인 점심시간 혼밥 vs 같이 먹기", + createdAt: "2026-04-14T13:49:00+09:00", + content: + "저는 혼자 밥 먹는 게 편한데 회사에서 막내라 혼자 밥 먹겠다고 하기 눈치보여요ㅠㅠ 혼밥하고 싶다고 말씀드려도 될까요?", + thumbnailUrl: "https://picsum.photos/400/250", + status: "ENDED", + endAt: "2026-04-14T23:59:00+09:00", + participantCount: 31, + options: [ + { optionId: 10, label: "혼밥이 편하다", voteCount: null, ratio: null }, + { optionId: 11, label: "그래도 밥은 같이 먹는게 맞다", voteCount: null, ratio: null }, + ], + myVote: { + voted: false, + selectedOptionId: null, + }, + emojiSummary: { LIKE: 21, SAD: 3, ANGRY: 8, WOW: 36 }, + myEmoji: "WOW", + commentCount: 81, +}; diff --git a/src/features/votes/api/mockVoteResult.ts b/src/features/votes/api/mockVoteResult.ts new file mode 100644 index 0000000..cc893c4 --- /dev/null +++ b/src/features/votes/api/mockVoteResult.ts @@ -0,0 +1,39 @@ +import type { VoteResult } from "../model/resultTypes"; + +export const mockVoteResult: VoteResult = { + voteId: 1, + title: "직장인 점심시간 혼밥 vs 같이 먹기", + createdAt: "2026-04-14T13:49:00+09:00", + content: + "저는 혼자 밥 먹는 게 편한데 회사에서 막내라 혼자 밥 먹겠다고 하기 눈치보여요ㅠㅠ 혼밥하고 싶다고 말씀드려도 될까요?", + thumbnailUrl: "https://picsum.photos/400/250", + status: "ENDED", + endAt: "2026-04-14T23:59:00+09:00", + participantCount: 520, + result: { + options: [ + { optionId: 10, label: "혼밥이 편하다", voteCount: 364, ratio: 70 }, + { optionId: 11, label: "그래도 밥은 같이 먹는게 맞다", voteCount: 156, ratio: 30 }, + ], + }, + myVote: { voted: true, selectedOptionId: 11 }, + insight: { + locked: false, + scope: "MY_SELECTION", + selectionCount: 156, + genderDistribution: { + female: { count: 96, ratio: 62 }, + male: { count: 60, ratio: 38 }, + }, + ageDistribution: [ + { ageGroup: "20s", ratio: 28, isMyGroup: true }, + { ageGroup: "30s", ratio: 52, isMyGroup: false }, + { ageGroup: "40s", ratio: 20, isMyGroup: false }, + ], + }, + aiInsight: { + available: true, + headline: '20대 여성 그룹에서 "같이 밥먹기"를 선택한 비율이 71%로 가장 높게 나타났어요.', + body: "MZ 세대를 중심으로 혼밥 문화가 확산되는 트렌드가 반영된 결과예요.", + }, +}; diff --git a/src/features/votes/api/voteDetailQuery.ts b/src/features/votes/api/voteDetailQuery.ts new file mode 100644 index 0000000..85e55dc --- /dev/null +++ b/src/features/votes/api/voteDetailQuery.ts @@ -0,0 +1,15 @@ +import { apiClient } from "@base/api/client"; +import { queryOptions } from "@tanstack/react-query"; +import type { VoteDetail } from "../model/types"; +// MOCK_START — 제거 시 이 줄부터 MOCK_END까지와 mockVoteDetail import를 삭제 +import { mockVoteDetail } from "./mockVoteDetail"; +// MOCK_END + +export const voteDetailQueryOptions = (voteId: string) => + queryOptions({ + queryKey: ["votes", voteId], + // MOCK_START — 제거 시 이 줄부터 MOCK_END까지 삭제 후 아래 실제 호출 주석 해제 + queryFn: () => Promise.resolve({ ...mockVoteDetail, voteId: Number(voteId) }), + // queryFn: () => apiClient.get(`/api/votes/${voteId}`).then((r) => r.data), + // MOCK_END + }); diff --git a/src/features/votes/api/voteEmoji.ts b/src/features/votes/api/voteEmoji.ts new file mode 100644 index 0000000..55b07eb --- /dev/null +++ b/src/features/votes/api/voteEmoji.ts @@ -0,0 +1,17 @@ +import { apiClient } from "@base/api/client"; +// MOCK_START — 제거 시 이 줄부터 MOCK_END까지와 EmojiType import를 삭제 +import type { EmojiType } from "../model/types"; +// MOCK_END +import type { EmojiResponse } from "../model/types"; + +export const reactEmoji = async (voteId: string, emoji: EmojiType | null): Promise => { + // MOCK_START — 제거 시 이 줄부터 MOCK_END까지 삭제 후 아래 실제 호출 주석 해제 + return Promise.resolve({ + emojiSummary: { LIKE: 21, SAD: 3, ANGRY: 8, WOW: emoji === "WOW" ? 37 : 36, total: 69 }, + myEmoji: emoji, + }); + // return apiClient + // .put(`/api/votes/${voteId}/emoji`, { emoji }) + // .then((r) => r.data); + // MOCK_END +}; diff --git a/src/features/votes/api/voteParticipate.ts b/src/features/votes/api/voteParticipate.ts new file mode 100644 index 0000000..a1a5154 --- /dev/null +++ b/src/features/votes/api/voteParticipate.ts @@ -0,0 +1,31 @@ +import { apiClient } from "@base/api/client"; +// MOCK_START — 제거 시 이 줄부터 MOCK_END까지와 VoteOption import를 삭제 +import type { VoteOption } from "../model/types"; +// MOCK_END +import type { ParticipateResponse } from "../model/types"; + +export const participateVote = async (voteId: string, optionId: number): Promise => { + // MOCK_START — 제거 시 이 줄부터 MOCK_END까지 삭제 후 아래 실제 호출 주석 해제 + const mockOptions: VoteOption[] = [ + { optionId: 10, label: "혼밥이 편하다", voteCount: 22, ratio: 70 }, + { optionId: 11, label: "그래도 밥은 같이 먹는게 맞다", voteCount: 9, ratio: 30 }, + ]; + return Promise.resolve({ + voteId: Number(voteId), + selectedOptionId: optionId, + options: mockOptions, + participantCount: 32, + remainingFreeVotes: null, + }); + // return apiClient + // .post(`/api/votes/${voteId}/participate`, { optionId }) + // .then((r) => r.data); + // MOCK_END +}; + +export const cancelVote = async (voteId: string): Promise => { + // MOCK_START — 제거 시 이 줄부터 MOCK_END까지 삭제 후 아래 실제 호출 주석 해제 + return Promise.resolve(); + // return apiClient.delete(`/api/votes/${voteId}/participate`).then(() => undefined); + // MOCK_END +}; diff --git a/src/features/votes/api/voteResultQuery.ts b/src/features/votes/api/voteResultQuery.ts new file mode 100644 index 0000000..eb7c18d --- /dev/null +++ b/src/features/votes/api/voteResultQuery.ts @@ -0,0 +1,15 @@ +import { apiClient } from "@base/api/client"; +import { queryOptions } from "@tanstack/react-query"; +import type { VoteResult } from "../model/resultTypes"; +// MOCK_START — 제거 시 이 줄부터 MOCK_END까지와 mockVoteResult import를 삭제 +import { mockVoteResult } from "./mockVoteResult"; +// MOCK_END + +export const voteResultQueryOptions = (voteId: string) => + queryOptions({ + queryKey: ["votes", voteId, "result"], + // MOCK_START — 제거 시 이 줄부터 MOCK_END까지 삭제 후 아래 실제 호출 주석 해제 + queryFn: () => Promise.resolve({ ...mockVoteResult, voteId: Number(voteId) }), + // queryFn: () => apiClient.get(`/api/votes/${voteId}/result`).then((r) => r.data), + // MOCK_END + }); diff --git a/src/features/votes/model/resultTypes.ts b/src/features/votes/model/resultTypes.ts new file mode 100644 index 0000000..822e42a --- /dev/null +++ b/src/features/votes/model/resultTypes.ts @@ -0,0 +1,40 @@ +export interface VoteResultOption { + optionId: number; + label: string; + voteCount: number; + ratio: number; +} + +export interface InsightUnlocked { + locked: false; + scope: "MY_SELECTION" | "TOTAL"; + selectionCount: number; + genderDistribution: { + female: { count: number; ratio: number }; + male: { count: number; ratio: number }; + }; + ageDistribution: Array<{ ageGroup: string; ratio: number; isMyGroup: boolean }>; +} + +export interface InsightLocked { + locked: true; + scope: null; + selectionCount: null; + genderDistribution: null; + ageDistribution: null; +} + +export interface VoteResult { + voteId: number; + title: string; + createdAt: string; + content: string; + thumbnailUrl: string | null; + status: "ENDED"; + endAt: string; + participantCount: number; + result: { options: VoteResultOption[] }; + myVote: { voted: boolean; selectedOptionId: number | null }; + insight: InsightUnlocked | InsightLocked; + aiInsight: { available: boolean; headline: string | null; body: string | null }; +} diff --git a/src/features/votes/model/types.ts b/src/features/votes/model/types.ts new file mode 100644 index 0000000..e229e74 --- /dev/null +++ b/src/features/votes/model/types.ts @@ -0,0 +1,53 @@ +export type VoteStatus = "ONGOING" | "ENDED"; + +export type EmojiType = "LIKE" | "SAD" | "ANGRY" | "WOW"; + +export interface VoteOption { + optionId: number; + label: string; + voteCount: number | null; + ratio: number | null; +} + +export interface VoteDetail { + voteId: number; + title: string; + createdAt: string; + content: string; + thumbnailUrl: string | null; + status: VoteStatus; + endAt: string; + participantCount: number; + options: VoteOption[]; + myVote: { + voted: boolean; + selectedOptionId: number | null; + }; + emojiSummary: { + LIKE: number; + SAD: number; + ANGRY: number; + WOW: number; + }; + myEmoji: EmojiType | null; + commentCount: number; +} + +export interface ParticipateResponse { + voteId: number; + selectedOptionId: number; + options: VoteOption[]; + participantCount: number; + remainingFreeVotes: number | null; +} + +export interface EmojiResponse { + emojiSummary: { + LIKE: number; + SAD: number; + ANGRY: number; + WOW: number; + total: number; + }; + myEmoji: EmojiType | null; +} diff --git a/src/features/votes/model/useVoteDetail.ts b/src/features/votes/model/useVoteDetail.ts new file mode 100644 index 0000000..8c00806 --- /dev/null +++ b/src/features/votes/model/useVoteDetail.ts @@ -0,0 +1,212 @@ +import { userQueryOptions } from "@features/auth/api/userQuery"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { voteDetailQueryOptions } from "../api/voteDetailQuery"; +import { reactEmoji } from "../api/voteEmoji"; +import { cancelVote, participateVote } from "../api/voteParticipate"; +import { voteResultQueryOptions } from "../api/voteResultQuery"; +import type { InsightUnlocked, VoteResultOption } from "./resultTypes"; +import type { EmojiType, VoteDetail } from "./types"; +import { primaryGroupIndex, primaryResultOptionId } from "./voteDetailUtils"; + +export type VoteUserType = "guest" | "member-voted" | "member-not-voted"; + +export interface EmojiItem { + type: EmojiType; + count: number | undefined; + isMine: boolean; + img: string; +} + +export interface GenderChartProps { + primary: { label: string; count: number; color: string }; + secondary: { label: string; count: number; color: string }; +} + +export interface AgeGroup { + label: string; + percentage: number; + isPrimary: boolean; + isMyGroup: boolean; +} + +export function useVoteDetail(voteId: string) { + const { data, isLoading: isVoteDetailLoading } = useQuery(voteDetailQueryOptions(voteId)); + const queryClient = useQueryClient(); + const queryKey = ["votes", voteId]; + + const isEnded = data?.status === "ENDED"; + + const { data: user, isLoading: isUserLoading } = useQuery(userQueryOptions()); + const isGuest = user === null; + + const { data: result, isLoading: isVoteResultLoading } = useQuery({ ...voteResultQueryOptions(voteId), enabled: isEnded }); + + const isInitialLoading = isVoteDetailLoading || isUserLoading || (isEnded && isVoteResultLoading); + + const voteUserType: VoteUserType = isGuest ? "guest" : result?.myVote.voted ? "member-voted" : "member-not-voted"; + + const participateMutation = useMutation({ + mutationFn: (optionId: number) => participateVote(voteId, optionId), + onMutate: async (optionId) => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, (old) => + old ? { ...old, myVote: { voted: true, selectedOptionId: optionId } } : old, + ); + return { previous }; + }, + onSuccess: (response) => { + queryClient.setQueryData(queryKey, (old) => + old + ? { + ...old, + myVote: { voted: true, selectedOptionId: response.selectedOptionId }, + options: response.options, + participantCount: response.participantCount, + } + : old, + ); + }, + onError: (_err, _optionId, context) => { + if (context?.previous) queryClient.setQueryData(queryKey, context.previous); + }, + }); + + const cancelMutation = useMutation({ + mutationFn: () => cancelVote(voteId), + onSuccess: () => queryClient.invalidateQueries({ queryKey }), + }); + + const emojiMutation = useMutation({ + mutationFn: (emoji: EmojiType | null) => reactEmoji(voteId, emoji), + onMutate: async (emoji) => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (old) => { + if (!old) return old; + const prev = old.myEmoji; + const next = emoji === prev ? null : emoji; + const updatedSummary = { ...old.emojiSummary }; + if (prev) updatedSummary[prev] = Math.max(0, updatedSummary[prev] - 1); + if (next) updatedSummary[next] = updatedSummary[next] + 1; + return { ...old, emojiSummary: updatedSummary, myEmoji: next }; + }); + + return { previous }; + }, + onSuccess: (response) => { + queryClient.setQueryData(queryKey, (old) => + old + ? { + ...old, + emojiSummary: { + LIKE: response.emojiSummary.LIKE, + SAD: response.emojiSummary.SAD, + ANGRY: response.emojiSummary.ANGRY, + WOW: response.emojiSummary.WOW, + }, + myEmoji: response.myEmoji, + } + : old, + ); + }, + onError: (_err, _emoji, context) => { + if (context?.previous) queryClient.setQueryData(queryKey, context.previous); + }, + }); + + const handleOptionClick = (optionId: number) => { + if (data?.myVote.voted) return; + participateMutation.mutate(optionId); + }; + + const emojiList: EmojiItem[] = [ + { + type: "LIKE", + count: data?.emojiSummary.LIKE, + isMine: data?.myEmoji === "LIKE", + img: "/assets/images/emoji/smiling-face.png", + }, + { + type: "SAD", + count: data?.emojiSummary.SAD, + isMine: data?.myEmoji === "SAD", + img: "/assets/images/emoji/crying-face.png", + }, + { + type: "ANGRY", + count: data?.emojiSummary.ANGRY, + isMine: data?.myEmoji === "ANGRY", + img: "/assets/images/emoji/enraged-face.png", + }, + { + type: "WOW", + count: data?.emojiSummary.WOW, + isMine: data?.myEmoji === "WOW", + img: "/assets/images/emoji/smiling-face-with-heart-eyes.png", + }, + ]; + + const resultOptions: VoteResultOption[] = result?.result.options ?? []; + const insight = result?.insight; + const unlockedInsight: InsightUnlocked | null = insight && !insight.locked ? (insight as InsightUnlocked) : null; + + const insightPrimaryOptionId: number | null = + voteUserType === "member-voted" && result?.myVote.selectedOptionId != null + ? result.myVote.selectedOptionId + : resultOptions.length > 0 + ? primaryResultOptionId(resultOptions) + : null; + + const genderChartProps: GenderChartProps = (() => { + if (voteUserType !== "guest" && unlockedInsight) { + const { female, male } = unlockedInsight.genderDistribution; + const isFemaleUser = user?.gender === "FEMALE"; + return { + primary: isFemaleUser + ? { label: "여성", count: female.count, color: "#9A9AF6" } + : { label: "남성", count: male.count, color: "#9A9AF6" }, + secondary: isFemaleUser + ? { label: "남성", count: male.count, color: "#EDECEF" } + : { label: "여성", count: female.count, color: "#EDECEF" }, + }; + } + return { + primary: { label: "여성", count: 80, color: "#9A9AF6" }, + secondary: { label: "남성", count: 50, color: "#EDECEF" }, + }; + })(); + + const ageGroups: AgeGroup[] = (() => { + if (voteUserType !== "guest" && unlockedInsight) { + return unlockedInsight.ageDistribution.map((ag, idx, arr) => ({ + label: ag.ageGroup.replace(/(\d+)s/, "$1대"), + percentage: ag.ratio, + isMyGroup: ag.isMyGroup, + isPrimary: voteUserType === "member-voted" ? ag.isMyGroup : idx === primaryGroupIndex(arr), + })); + } + return [ + { label: "20대", percentage: 28, isPrimary: true, isMyGroup: false }, + { label: "30대", percentage: 52, isPrimary: false, isMyGroup: false }, + { label: "40대", percentage: 20, isPrimary: false, isMyGroup: false }, + ]; + })(); + + return { + data, + result, + isEnded, + isInitialLoading, + voteUserType, + insightPrimaryOptionId, + genderChartProps, + ageGroups, + emojiList, + handleOptionClick, + cancelMutation, + emojiMutation, + participateMutation, + }; +} diff --git a/src/features/votes/model/voteDetailUtils.ts b/src/features/votes/model/voteDetailUtils.ts new file mode 100644 index 0000000..2c32351 --- /dev/null +++ b/src/features/votes/model/voteDetailUtils.ts @@ -0,0 +1,10 @@ +import type { VoteResultOption } from "./resultTypes"; + +export function primaryResultOptionId(options: VoteResultOption[]): number { + return options.reduce((best, opt) => (opt.ratio > best.ratio ? opt : best)).optionId; +} + +export function primaryGroupIndex(groups: Array<{ ratio: number }>): number { + const max = Math.max(...groups.map((g) => g.ratio)); + return groups.findIndex((g) => g.ratio === max); +} From 67e402f22d5d198a8230444d67b809a0389e760d Mon Sep 17 00:00:00 2001 From: youngkwang choi Date: Tue, 5 May 2026 20:08:06 +0900 Subject: [PATCH 5/7] feat: add vote detail page UI components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 투표 상세 페이지를 구성하는 UI 컴포넌트 계층입니다. 컴포넌트 구조 (VoteDetailPage가 최상위 조합): - VoteHeader: 뒤로가기 / 제목 / 공유 버튼 - VoteContent: 투표 제목, 작성 시각, 본문, 썸네일 - VoteOptionsSection: 옵션 선택 + VoteBar(퍼센트 시각화) - VoteReactionBar: LIKE·SAD·ANGRY·WOW 이모지 반응 - VoteTimeRemaining: 마감까지 남은 시간 - VoteInsightSection: 투표 종료 후 성별/연령대 분포 분석 - LockedContentOverlay: 인사이트 잠금 해제 유도 오버레이 함께 수정: - base/ui/AgeBarChart: isPrimary → isMyGroup으로 변수명 변경 (현재 사용자의 연령대 그룹을 강조하는 의미를 명확히 표현) Co-Authored-By: Claude Sonnet 4.6 --- src/base/ui/AgeBarChart/index.tsx | 4 +- .../votes/ui/LockedContentOverlay.tsx | 16 +++ src/features/votes/ui/VoteBar.tsx | 17 +++ src/features/votes/ui/VoteContent.tsx | 19 +++ src/features/votes/ui/VoteDetailPage.tsx | 97 +++++++++++++++ src/features/votes/ui/VoteHeader.tsx | 18 +++ src/features/votes/ui/VoteInsightSection.tsx | 110 ++++++++++++++++++ src/features/votes/ui/VoteOptionsSection.tsx | 78 +++++++++++++ src/features/votes/ui/VoteReactionBar.tsx | 45 +++++++ src/features/votes/ui/VoteTimeRemaining.tsx | 25 ++++ 10 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 src/features/votes/ui/LockedContentOverlay.tsx create mode 100644 src/features/votes/ui/VoteBar.tsx create mode 100644 src/features/votes/ui/VoteContent.tsx create mode 100644 src/features/votes/ui/VoteDetailPage.tsx create mode 100644 src/features/votes/ui/VoteHeader.tsx create mode 100644 src/features/votes/ui/VoteInsightSection.tsx create mode 100644 src/features/votes/ui/VoteOptionsSection.tsx create mode 100644 src/features/votes/ui/VoteReactionBar.tsx create mode 100644 src/features/votes/ui/VoteTimeRemaining.tsx diff --git a/src/base/ui/AgeBarChart/index.tsx b/src/base/ui/AgeBarChart/index.tsx index bee8501..63c94d4 100644 --- a/src/base/ui/AgeBarChart/index.tsx +++ b/src/base/ui/AgeBarChart/index.tsx @@ -42,10 +42,10 @@ function AgeBarGroup({ group, animated, duration }: AgeBarGroupProps) {
- {label} + {label} {isMyGroup && 내 그룹}
- + {percentage}%
diff --git a/src/features/votes/ui/LockedContentOverlay.tsx b/src/features/votes/ui/LockedContentOverlay.tsx new file mode 100644 index 0000000..0227fe8 --- /dev/null +++ b/src/features/votes/ui/LockedContentOverlay.tsx @@ -0,0 +1,16 @@ +const LockedContentOverlay = () => { + return ( +
+ +

더 자세한 결과가 궁금하신가요?

+

+ 로그인하면 성별/연령대 세그먼트 분석 결과와 채팅 반응까지 모두 볼 수 있어요 +

+ +
+ ); +}; + +export default LockedContentOverlay; diff --git a/src/features/votes/ui/VoteBar.tsx b/src/features/votes/ui/VoteBar.tsx new file mode 100644 index 0000000..f801306 --- /dev/null +++ b/src/features/votes/ui/VoteBar.tsx @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +export function VoteBar({ ratio, isSelected }: { ratio: number; isSelected: boolean }) { + const [animatedWidth, setAnimatedWidth] = useState(0); + + useEffect(() => { + const id = setTimeout(() => setAnimatedWidth(ratio), 0); + return () => clearTimeout(id); + }, [ratio]); + + return ( + + ); +} diff --git a/src/features/votes/ui/VoteContent.tsx b/src/features/votes/ui/VoteContent.tsx new file mode 100644 index 0000000..e9e90ad --- /dev/null +++ b/src/features/votes/ui/VoteContent.tsx @@ -0,0 +1,19 @@ +import dayjs from "dayjs"; + +interface VoteContentProps { + title: string | undefined; + createdAt: string | undefined; + content: string | undefined; + thumbnailUrl: string | null | undefined; +} + +export function VoteContent({ title, createdAt, content, thumbnailUrl }: VoteContentProps) { + return ( + <> +

{title}

+
{dayjs(createdAt).format("YYYY.MM.DD HH:mm")}
+

{content}

+ {thumbnailUrl ? :
} + + ); +} diff --git a/src/features/votes/ui/VoteDetailPage.tsx b/src/features/votes/ui/VoteDetailPage.tsx new file mode 100644 index 0000000..5f8be19 --- /dev/null +++ b/src/features/votes/ui/VoteDetailPage.tsx @@ -0,0 +1,97 @@ +import { Spinner } from "@base/ui/Spinner"; +import { useVoteDetail } from "../model/useVoteDetail"; +import { VoteContent } from "./VoteContent"; +import { VoteHeader } from "./VoteHeader"; +import { VoteInsightSection } from "./VoteInsightSection"; +import { VoteOptionsSection } from "./VoteOptionsSection"; +import { VoteReactionBar } from "./VoteReactionBar"; + +export function VoteDetailPage({ voteId }: { voteId: string }) { + const { + data, + result, + isEnded, + isInitialLoading, + voteUserType, + insightPrimaryOptionId, + genderChartProps, + ageGroups, + emojiList, + handleOptionClick, + cancelMutation, + emojiMutation, + participateMutation, + } = useVoteDetail(voteId); + + if (isInitialLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+ + cancelMutation.mutate()} + isCancelPending={cancelMutation.isPending} + isParticipatePending={participateMutation.isPending} + /> + emojiMutation.mutate(type)} + isEmojiPending={emojiMutation.isPending} + /> +
+ + {isEnded && result && ( + + )} +
+ + {isEnded && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/features/votes/ui/VoteHeader.tsx b/src/features/votes/ui/VoteHeader.tsx new file mode 100644 index 0000000..7c7bc87 --- /dev/null +++ b/src/features/votes/ui/VoteHeader.tsx @@ -0,0 +1,18 @@ +export function VoteHeader({ isEnded }: { isEnded: boolean }) { + return ( +
+
+ +

{isEnded ? "투표 마감 최종결과" : "투표 상세"}

+
+ + {!isEnded && ( + + )} +
+ ); +} diff --git a/src/features/votes/ui/VoteInsightSection.tsx b/src/features/votes/ui/VoteInsightSection.tsx new file mode 100644 index 0000000..052ea7f --- /dev/null +++ b/src/features/votes/ui/VoteInsightSection.tsx @@ -0,0 +1,110 @@ +import { AgeBarChart } from "@base/ui/AgeBarChart"; +import { GenderDonutChart } from "@base/ui/DonutChart"; +import type { VoteResultOption } from "../model/resultTypes"; +import type { AgeGroup, GenderChartProps, VoteUserType } from "../model/useVoteDetail"; +import LockedContentOverlay from "./LockedContentOverlay"; + +interface VoteInsightSectionProps { + resultOptions: VoteResultOption[]; + insightPrimaryOptionId: number | null; + voteUserType: VoteUserType; + genderChartProps: GenderChartProps; + ageGroups: AgeGroup[]; + aiInsight: { available: boolean; headline: string | null; body: string | null }; +} + +export function VoteInsightSection({ + resultOptions, + insightPrimaryOptionId, + voteUserType, + genderChartProps, + ageGroups, + aiInsight, +}: VoteInsightSectionProps) { + const isGuest = voteUserType === "guest"; + + return ( + <> +
+ +
+

분석 인사이트

+ +
+
+ {voteUserType === "member-voted" && 나의 선택} + +
+ {resultOptions.map((option) => { + const isPrimary = option.optionId === insightPrimaryOptionId; + return ( +
+ {option.label} ({option.ratio}%) +
+ ); + })} +
+ +

+ 총 365명이 선택했어요 +

+
+ +
+ +
+
+
+ 성별 분포 +
+ +
+
+ +
+ +
+ 연령대별 분포 +
+ +
+
+ + {aiInsight.available && ( +
+
+ + AI 인사이트 +
+ +

{aiInsight.headline}

+

{aiInsight.body}

+ +
+ +

+ AI 인사이트는 투표 데이터를 기반으로 자동 생성된 분석이에요. 개인의 가치관과 다를 수 있으니 참고만 + 해주세요. +

+
+
+ )} +
+ + {isGuest && ( +
+
+ +
+
+ )} +
+
+
+ + ); +} diff --git a/src/features/votes/ui/VoteOptionsSection.tsx b/src/features/votes/ui/VoteOptionsSection.tsx new file mode 100644 index 0000000..8d92bb0 --- /dev/null +++ b/src/features/votes/ui/VoteOptionsSection.tsx @@ -0,0 +1,78 @@ +import type { VoteOption } from "../model/types"; +import { VoteBar } from "./VoteBar"; +import { VoteTimeRemaining } from "./VoteTimeRemaining"; + +interface VoteOptionsSectionProps { + options: VoteOption[] | undefined; + myVote: { voted: boolean; selectedOptionId: number | null } | undefined; + participantCount: number | undefined; + endAt: string | undefined; + onOptionClick: (optionId: number) => void; + onCancel: () => void; + isCancelPending: boolean; + isParticipatePending: boolean; +} + +export function VoteOptionsSection({ + options, + myVote, + participantCount, + endAt, + onOptionClick, + onCancel, + isCancelPending, + isParticipatePending, +}: VoteOptionsSectionProps) { + return ( +
+
+ + 투표 +
+ +
+ + + {options?.map((option) => { + const isSelected = myVote?.selectedOptionId === option.optionId; + const hasVoted = myVote?.voted ?? false; + return ( + + ); + })} +
+ +
+ + + {participantCount}명 참여 + + + + + 남은 시간 + + + + {endAt && } + +
+
+ ); +} diff --git a/src/features/votes/ui/VoteReactionBar.tsx b/src/features/votes/ui/VoteReactionBar.tsx new file mode 100644 index 0000000..dba1956 --- /dev/null +++ b/src/features/votes/ui/VoteReactionBar.tsx @@ -0,0 +1,45 @@ +import { Dropdown } from "@base/ui/Dropdown"; +import type { EmojiType } from "../model/types"; +import type { EmojiItem } from "../model/useVoteDetail"; + +interface VoteReactionBarProps { + emojiList: EmojiItem[]; + commentCount: number | undefined; + onEmojiClick: (type: EmojiType) => void; + isEmojiPending: boolean; +} + +export function VoteReactionBar({ emojiList, commentCount, onEmojiClick, isEmojiPending }: VoteReactionBarProps) { + return ( +
+ + 이모지 선택 + 56 + + } + > +
+ {emojiList.map(({ type, count, isMine, img }) => ( + + ))} +
+
+ + +
+ ); +} diff --git a/src/features/votes/ui/VoteTimeRemaining.tsx b/src/features/votes/ui/VoteTimeRemaining.tsx new file mode 100644 index 0000000..7f89fcf --- /dev/null +++ b/src/features/votes/ui/VoteTimeRemaining.tsx @@ -0,0 +1,25 @@ +import dayjs from "dayjs"; +import { useEffect, useState } from "react"; + +export function VoteTimeRemaining({ endAt }: { endAt: string }) { + const [remainingMs, setRemainingMs] = useState(() => dayjs(endAt).diff(dayjs())); + + useEffect(() => { + const id = setInterval(() => { + const next = dayjs(endAt).diff(dayjs()); + setRemainingMs(next); + if (next <= 0) clearInterval(id); + }, 1000); + return () => clearInterval(id); + }, [endAt]); + + if (remainingMs <= 0) return 투표 종료; + + const total = Math.floor(remainingMs / 1000); + const pad = (n: number) => String(n).padStart(2, "0"); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + + return {`${pad(h)}:${pad(m)}:${pad(s)} 남음`}; +} From 7e78b48f389d3876b9eae4f21f11a69d3d93ba1c Mon Sep 17 00:00:00 2001 From: youngkwang choi Date: Tue, 5 May 2026 20:08:15 +0900 Subject: [PATCH 6/7] feat: wire up home and vote detail page routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TanStack Router 파일 기반 라우팅에 페이지를 연결합니다. - /home → HomePage - /votes/:voteId → VoteDetailPage (voteId 파라미터 전달) Co-Authored-By: Claude Sonnet 4.6 --- src/pages/routes/home.tsx | 2 +- src/pages/routes/votes.$voteId.tsx | 222 +---------------------------- 2 files changed, 4 insertions(+), 220 deletions(-) diff --git a/src/pages/routes/home.tsx b/src/pages/routes/home.tsx index 7d09b1e..8f741f0 100644 --- a/src/pages/routes/home.tsx +++ b/src/pages/routes/home.tsx @@ -1,5 +1,5 @@ -import { createFileRoute } from "@tanstack/react-router"; import { HomePage } from "@features/home/HomePage"; +import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/home")({ component: HomePage, diff --git a/src/pages/routes/votes.$voteId.tsx b/src/pages/routes/votes.$voteId.tsx index f9c0296..d364050 100644 --- a/src/pages/routes/votes.$voteId.tsx +++ b/src/pages/routes/votes.$voteId.tsx @@ -1,6 +1,4 @@ -import { AgeBarChart } from "@base/ui/AgeBarChart"; -import { GenderDonutChart } from "@base/ui/DonutChart"; -import { Dropdown } from "@base/ui/Dropdown"; +import { VoteDetailPage } from "@features/votes/ui/VoteDetailPage"; import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/votes/$voteId")({ @@ -8,220 +6,6 @@ export const Route = createFileRoute("/votes/$voteId")({ }); function RouteComponent() { - return ( -
-
-
- -

투표 상세

-
- - -
- -
- {/* Content */} -
-

직장인 점심시간 혼밥 vs 같이 먹기

-
2026.04.14 13:49
-

- 저는 혼자 밥 먹는 게 편한데 회사에서 막내라 혼자 밥 먹겠다고 하기 눈치보여요ㅠㅠ 혼밥하고 싶다고 말씀드려도 - 될까요? -

- - - {/* Vote */} -
-
- - 투표 -
- -
- - - - - {/* */} -
- -
- - - 31명 참여 - - - - - 남은 시간 - - - - 11:23:47 남음 - -
-
- - {/* Emoji */} -
- - 이모지 선택 - 56 - - } - > -
- - - - - - - -
-
- - -
-
- -
- - {/* Insight */} -
-

분석 인사이트

- -
-
- 나의 선택 - -
-
- 혼밥이 편하다 (70%) -
-
- 그래도 밥은 같이 먹는게 맞다 (30%) -
-
-
- -
- -
- 성별 분포 -
- -
-
- -
- -
- 연령대별 분포 -
- -
-
- -
-
- - AI 인사이트 -
- -

- 20대 여성 그룹에서 "같이 밥먹기"를 선택한 비율이 71%로 가장 높게 나타났어요. MZ 세대를 중심으로 혼밥 - 문화가 확산되는 트렌드가 반영된 결과예요. -

- -
- -

- AI 인사이트는 투표 데이터를 기반으로 자동 생성된 분석이에요. 개인의 가치관과 다를 수 있으니 참고만 - 해주세요. -

-
-
-
-
-
- -
- - -
-
- ); + const { voteId } = Route.useParams(); + return ; } From 875a1d43c8a1ec77e7cbdfdb8975ee6639e875ee Mon Sep 17 00:00:00 2001 From: youngkwang choi Date: Wed, 6 May 2026 10:54:48 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/votes/model/useVoteDetail.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/votes/model/useVoteDetail.ts b/src/features/votes/model/useVoteDetail.ts index 8c00806..d55d67e 100644 --- a/src/features/votes/model/useVoteDetail.ts +++ b/src/features/votes/model/useVoteDetail.ts @@ -39,11 +39,14 @@ export function useVoteDetail(voteId: string) { const { data: user, isLoading: isUserLoading } = useQuery(userQueryOptions()); const isGuest = user === null; - const { data: result, isLoading: isVoteResultLoading } = useQuery({ ...voteResultQueryOptions(voteId), enabled: isEnded }); + const { data: result, isLoading: isVoteResultLoading } = useQuery({ + ...voteResultQueryOptions(voteId), + enabled: isEnded, + }); const isInitialLoading = isVoteDetailLoading || isUserLoading || (isEnded && isVoteResultLoading); - const voteUserType: VoteUserType = isGuest ? "guest" : result?.myVote.voted ? "member-voted" : "member-not-voted"; + const voteUserType: VoteUserType = isGuest ? "guest" : data?.myVote.voted ? "member-voted" : "member-not-voted"; const participateMutation = useMutation({ mutationFn: (optionId: number) => participateVote(voteId, optionId),