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/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/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/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();
+ }}
+ >
+
+
,
+ 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 (
+
+ );
+}
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) 정의
+- 환경별 설정값 및 환경 변수
+- 도메인별 규칙 및 제약 조건
+- 조건부/동적 설정 포함 고급 설정 관리
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;
+}
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..d55d67e
--- /dev/null
+++ b/src/features/votes/model/useVoteDetail.ts
@@ -0,0 +1,215 @@
+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" : data?.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);
+}
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)} 남음`};
+}
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 ;
}