From bf822d3ec475dffcbb22954af53c57215830f155 Mon Sep 17 00:00:00 2001 From: Hyeonjun0527 Date: Wed, 4 Feb 2026 12:17:30 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=84=9C=EB=B2=84=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=20hydration=20=EB=B6=88=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=20=EC=98=A4=EB=A5=98,=20=EC=8B=9C=EA=B0=84=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20=EC=95=84=EC=B9=B4=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20ui=20=EB=B3=B4=EC=99=84=20=EB=B0=B8=EB=9F=B0?= =?UTF-8?q?=EC=8A=A4=20=EA=B2=8C=EC=9E=84=20>=20url=20=EA=B3=B5=EC=9C=A0?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20(=ED=88=AC=ED=91=9C=20=EA=B8=80=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EC=97=90=20=EB=8C=80=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=EB=A1=9C=20=EA=B3=B5=EC=9C=A0=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EA=B2=8C=EB=81=94)=20=EB=B0=B8=EB=9F=B0?= =?UTF-8?q?=EC=8A=A4=EA=B2=8C=EC=9E=84=EC=97=90=EC=84=9C=20=ED=94=84?= =?UTF-8?q?=EC=82=AC=EA=B0=80=20=EC=95=88=EB=B3=B4=EC=9E=84=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20=EB=82=98=EC=9D=98=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20ui=EC=97=90=20=EB=8B=B5=EB=B3=80=EC=9E=90?= =?UTF-8?q?=20->=20=EC=A7=80=EC=9B=90=EC=9E=90=20=EC=9D=B4=EB=A0=87?= =?UTF-8?q?=EA=B2=8C=20=EB=AC=B8=EA=B5=AC=20=EB=B0=94=EA=BF=88.=20?= =?UTF-8?q?=EB=B0=B8=EB=9F=B0=EC=8A=A4=EA=B2=8C=EC=9E=84=EC=97=90=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=ED=95=84=ED=84=B0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80.=20=EB=B0=B8=EB=9F=B0=EC=8A=A4=EA=B2=8C?= =?UTF-8?q?=EC=9E=84=20=EA=B8=80=20=EC=9E=91=EC=84=B1=EC=97=90=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=ED=83=9C=EA=B7=B8=20=EB=AA=A9=EB=A1=9D=20=EB=B3=B4?= =?UTF-8?q?=EC=97=AC=EC=A7=80=EB=8F=84=EB=A1=9D=20=ED=95=A8.=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=EC=9E=90=20->=20=EC=A7=80=EC=9B=90=EC=9E=90.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: 다중태그 지원 fix: 검색 자동완성 및 ui 다듬기 fix: 복구 fix: ui디자인 변경 어드민 기능 삭제 feat: 스터디 튜토리얼 추가 refactor: 코드리팩토링 refactor: 코드 리팩토링 fix: ui 수정 --- docs/balance-game-tag-filter-request.md | 46 ++ package.json | 1 + src/api/client/api-logger.ts | 86 ++++ src/api/client/axios.server.ts | 3 + src/api/client/axios.ts | 4 + src/api/client/axiosV2.server.ts | 3 + src/api/client/axiosV2.ts | 4 + src/app/(service)/home/page.tsx | 1 + src/app/(service)/insights/page.tsx | 11 - .../(service)/insights/weekly/[id]/page.tsx | 12 - src/app/(service)/insights/weekly/page.tsx | 5 - src/components/card/voting-card.tsx | 31 +- src/components/discussion/comment-list.tsx | 5 +- src/components/home/tab-navigation.tsx | 29 +- .../my-participating-studies-section.tsx | 8 +- .../study-history/study-history-row.tsx | 2 +- src/components/tutorial/index.ts | 2 + src/components/tutorial/tutorial-overlay.tsx | 480 ++++++++++++++++++ src/components/ui/action-pill-button.tsx | 51 ++ .../ui/filters/filter-pill-button.tsx | 36 ++ src/components/ui/filters/sort-dropdown.tsx | 54 ++ .../ui/filters/view-mode-toggle.tsx | 51 ++ src/components/ui/inline-section-header.tsx | 35 ++ src/components/ui/input/base.tsx | 5 +- src/components/ui/modal-shell.tsx | 42 ++ src/components/ui/section-header.tsx | 49 ++ src/components/ui/section-shell.tsx | 16 + src/components/ui/stat-item.tsx | 53 ++ src/components/ui/toast.tsx | 4 +- src/components/ui/tooltip/index.tsx | 4 +- src/components/voting/vote-timer.tsx | 40 +- src/components/voting/voting-create-modal.tsx | 429 +++++----------- .../voting/voting-deadline-field.tsx | 54 ++ src/components/voting/voting-detail-view.tsx | 83 ++- src/components/voting/voting-edit-modal.tsx | 3 + src/components/voting/voting-modal-footer.tsx | 50 ++ src/components/voting/voting-modal-header.tsx | 34 ++ .../voting/voting-option-fields.tsx | 86 ++++ src/components/voting/voting-tag-field.tsx | 56 ++ .../study/group/channel/ui/sub-comments.tsx | 2 +- .../study/interview/ui/study-done-modal.tsx | 177 ++++--- .../study/interview/ui/study-ready-modal.tsx | 168 +++--- .../api/get-archive-search-suggestions.ts | 46 ++ .../archive/api/toggle-visibility.ts | 14 + .../one-to-one/archive/api/update-archive.ts | 26 + .../one-to-one/archive/model/archive-keys.ts | 14 + .../study/one-to-one/archive/model/index.ts | 9 + .../archive/model/use-archive-actions.ts | 23 + .../archive/model/use-archive-query.ts | 9 +- .../use-archive-search-suggestions-query.ts | 27 + .../archive/model/use-bookmark-mutation.ts | 18 +- .../archive/model/use-like-mutation.ts | 10 +- .../model/use-update-archive-mutation.ts | 58 +++ .../archive/model/use-view-mutation.ts | 4 +- .../archive/model/use-visibility-mutation.ts | 50 ++ .../one-to-one/archive/ui/archive-filters.tsx | 312 +++++++++--- .../one-to-one/archive/ui/archive-grid.tsx | 313 +++++++++--- .../one-to-one/archive/ui/archive-header.tsx | 35 +- .../one-to-one/archive/ui/archive-list.tsx | 331 +++++++++--- .../archive/ui/archive-pagination.tsx | 39 ++ .../archive/ui/archive-tab-client.tsx | 135 +++-- .../archive/ui/use-archive-filters.ts | 70 +++ .../api/balance-game-api.server.ts | 6 +- .../balance-game/api/balance-game-api.ts | 43 +- .../get-balance-game-search-suggestions.ts | 42 ++ .../one-to-one/balance-game/const/tags.ts | 3 + .../balance-game/model/balance-game-keys.ts | 31 ++ .../one-to-one/balance-game/model/index.ts | 18 + .../model/use-balance-game-mutation.ts | 32 +- .../model/use-balance-game-query.ts | 53 +- ...e-balance-game-search-suggestions-query.ts | 31 ++ .../ui/balance-game-filters-bar.tsx | 363 +++++++++++++ .../balance-game/ui/balance-game-page.tsx | 248 --------- .../balance-game/ui/community-tab-client.tsx | 306 +++++------ .../balance-game/ui/filter-pill-button.tsx | 29 +- .../balance-game/ui/tag-autocomplete.tsx | 251 +++++++++ .../ui/use-balance-game-filters.ts | 70 +++ .../balance-game/ui/use-infinite-scroll.ts | 50 ++ .../hall-of-fame/ui/hall-of-fame-constants.ts | 57 +++ .../hall-of-fame/ui/hall-of-fame-header.tsx | 24 + .../ui/hall-of-fame-mvp-section.tsx | 33 ++ .../ui/hall-of-fame-ranker-section.tsx | 81 +++ .../ui/hall-of-fame-tab-client.tsx | 346 +------------ .../hall-of-fame/ui/mvp-team-card.tsx | 119 +++++ .../one-to-one/hall-of-fame/ui/rank-badge.tsx | 31 ++ .../hall-of-fame/ui/ranker-list-item.tsx | 64 +++ .../ui/study-history-calendar-section.tsx | 27 + .../history/ui/study-history-header.tsx | 88 ++++ .../history/ui/study-history-list-section.tsx | 41 ++ .../history/ui/study-history-pagination.tsx | 38 ++ .../history/ui/study-history-summary.tsx | 18 + .../history/ui/study-history-tab-client.tsx | 152 ++---- .../history/ui/study-history-utils.ts | 31 ++ .../schedule/model/study-tutorial-steps.ts | 69 +++ .../schedule/model/tutorial-mock.ts | 20 + .../one-to-one/schedule/ui/home-study-tab.tsx | 5 +- .../one-to-one/schedule/ui/study-card.tsx | 54 +- .../schedule/ui/study-tutorial-controller.tsx | 55 ++ .../schedule/ui/today-study-card.tsx | 149 ++++-- .../study/one-to-one/ui/one-on-one-page.tsx | 13 - .../participation/ui/reservation-list.tsx | 21 +- src/hooks/use-scroll-to-home-content.ts | 76 +++ src/types/archive.ts | 30 +- src/types/balance-game.ts | 21 +- src/types/schemas/zod-schema.ts | 2 +- src/utils/voting-id.ts | 14 + src/widgets/home/study-list-table.tsx | 47 +- yarn.lock | 5 + 108 files changed, 5340 insertions(+), 1825 deletions(-) create mode 100644 docs/balance-game-tag-filter-request.md create mode 100644 src/api/client/api-logger.ts delete mode 100644 src/app/(service)/insights/weekly/[id]/page.tsx delete mode 100644 src/app/(service)/insights/weekly/page.tsx create mode 100644 src/components/tutorial/index.ts create mode 100644 src/components/tutorial/tutorial-overlay.tsx create mode 100644 src/components/ui/action-pill-button.tsx create mode 100644 src/components/ui/filters/filter-pill-button.tsx create mode 100644 src/components/ui/filters/sort-dropdown.tsx create mode 100644 src/components/ui/filters/view-mode-toggle.tsx create mode 100644 src/components/ui/inline-section-header.tsx create mode 100644 src/components/ui/modal-shell.tsx create mode 100644 src/components/ui/section-header.tsx create mode 100644 src/components/ui/section-shell.tsx create mode 100644 src/components/ui/stat-item.tsx create mode 100644 src/components/voting/voting-deadline-field.tsx create mode 100644 src/components/voting/voting-modal-footer.tsx create mode 100644 src/components/voting/voting-modal-header.tsx create mode 100644 src/components/voting/voting-option-fields.tsx create mode 100644 src/components/voting/voting-tag-field.tsx create mode 100644 src/features/study/one-to-one/archive/api/get-archive-search-suggestions.ts create mode 100644 src/features/study/one-to-one/archive/api/toggle-visibility.ts create mode 100644 src/features/study/one-to-one/archive/api/update-archive.ts create mode 100644 src/features/study/one-to-one/archive/model/archive-keys.ts create mode 100644 src/features/study/one-to-one/archive/model/index.ts create mode 100644 src/features/study/one-to-one/archive/model/use-archive-search-suggestions-query.ts create mode 100644 src/features/study/one-to-one/archive/model/use-update-archive-mutation.ts create mode 100644 src/features/study/one-to-one/archive/model/use-visibility-mutation.ts create mode 100644 src/features/study/one-to-one/archive/ui/archive-pagination.tsx create mode 100644 src/features/study/one-to-one/archive/ui/use-archive-filters.ts create mode 100644 src/features/study/one-to-one/balance-game/api/get-balance-game-search-suggestions.ts create mode 100644 src/features/study/one-to-one/balance-game/const/tags.ts create mode 100644 src/features/study/one-to-one/balance-game/model/balance-game-keys.ts create mode 100644 src/features/study/one-to-one/balance-game/model/index.ts create mode 100644 src/features/study/one-to-one/balance-game/model/use-balance-game-search-suggestions-query.ts create mode 100644 src/features/study/one-to-one/balance-game/ui/balance-game-filters-bar.tsx delete mode 100644 src/features/study/one-to-one/balance-game/ui/balance-game-page.tsx create mode 100644 src/features/study/one-to-one/balance-game/ui/tag-autocomplete.tsx create mode 100644 src/features/study/one-to-one/balance-game/ui/use-balance-game-filters.ts create mode 100644 src/features/study/one-to-one/balance-game/ui/use-infinite-scroll.ts create mode 100644 src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants.ts create mode 100644 src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-header.tsx create mode 100644 src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-mvp-section.tsx create mode 100644 src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-ranker-section.tsx create mode 100644 src/features/study/one-to-one/hall-of-fame/ui/mvp-team-card.tsx create mode 100644 src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsx create mode 100644 src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx create mode 100644 src/features/study/one-to-one/history/ui/study-history-calendar-section.tsx create mode 100644 src/features/study/one-to-one/history/ui/study-history-header.tsx create mode 100644 src/features/study/one-to-one/history/ui/study-history-list-section.tsx create mode 100644 src/features/study/one-to-one/history/ui/study-history-pagination.tsx create mode 100644 src/features/study/one-to-one/history/ui/study-history-summary.tsx create mode 100644 src/features/study/one-to-one/history/ui/study-history-utils.ts create mode 100644 src/features/study/one-to-one/schedule/model/study-tutorial-steps.ts create mode 100644 src/features/study/one-to-one/schedule/model/tutorial-mock.ts create mode 100644 src/features/study/one-to-one/schedule/ui/study-tutorial-controller.tsx create mode 100644 src/hooks/use-scroll-to-home-content.ts create mode 100644 src/utils/voting-id.ts diff --git a/docs/balance-game-tag-filter-request.md b/docs/balance-game-tag-filter-request.md new file mode 100644 index 00000000..9aa89e15 --- /dev/null +++ b/docs/balance-game-tag-filter-request.md @@ -0,0 +1,46 @@ +# 밸런스게임 태그 검색/필터 요청서 + +## 목적 + +- 태그 필터를 드롭다운이 아닌 **검색 입력 + Enter 적용** 방식으로 변경. +- **다중 태그 필터** 지원 (입력 후 Enter로 태그 추가). +- 필터 적용 시 **입력된 모든 태그를 포함**하는 밸런스게임 목록만 반환. + +## 변경 요약 + +- 프론트는 태그 입력 후 Enter 또는 "적용" 버튼 클릭 시 `tag` 쿼리로 요청 +- 입력 길이 제한: **최대 40자** +- 태그 필터 해제 시 `tag` 파라미터 제거 + +## API 요청 + +### GET /api/v1/balance-game + +- 기존 목록 API 유지 +- Query Params: + - `page` (int, 1-based) + - `size` (int) + - `sort` (`latest` | `popular`) + - `status` (`active` | `closed`, optional) +- `tags` (string, optional) — `tag1,tag2,tag3` 형태의 comma-separated + +### 태그 필터 동작 + +- `tags`가 전달되면 **모든 태그를 포함**한 투표만 반환 +- 대소문자/공백 처리 정책은 백엔드에서 일관되게 적용 +- `tags`가 비어있거나 누락되면 전체 반환 + +## 입력 제약 (프론트 기준) + +- 태그 길이: 1~40자 +- 다중 태그 필터 지원 + +## 응답 + +- 기존 밸런스게임 목록 응답 스키마 그대로 + +## 예시 + +``` +GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react +``` diff --git a/package.json b/package.json index 234db76c..7b10dfc1 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "embla-carousel-react": "^8.6.0", "framer-motion": "^12.27.1", "googleapis": "^164.1.0", + "hashids": "^2.3.0", "lucide-react": "^0.475.0", "next": "15.2.8", "postcss": "^8.5.2", diff --git a/src/api/client/api-logger.ts b/src/api/client/api-logger.ts new file mode 100644 index 00000000..717fec65 --- /dev/null +++ b/src/api/client/api-logger.ts @@ -0,0 +1,86 @@ +import type { + AxiosError, + AxiosInstance, + InternalAxiosRequestConfig, +} from 'axios'; + +const shouldLog = process.env.NODE_ENV !== 'production'; + +const normalizeUrl = (config: InternalAxiosRequestConfig) => { + const base = config.baseURL ?? ''; + const url = config.url ?? ''; + + if (!base) return url; + if (url.startsWith('http://') || url.startsWith('https://')) return url; + + return `${base.replace(/\/$/, '')}/${url.replace(/^\//, '')}`; +}; + +const stringifyParams = (params: InternalAxiosRequestConfig['params']) => { + if (!params) return ''; + + try { + return JSON.stringify(params); + } catch { + return ''; + } +}; + +const stringifyData = (data: unknown) => { + if (data === undefined) return ''; + + if (typeof data === 'string') return data; + + try { + return JSON.stringify(data); + } catch { + return String(data); + } +}; + +export const attachApiLogger = (instance: AxiosInstance, label: string) => { + if (!shouldLog) return; + + instance.interceptors.request.use((config) => { + const method = (config.method || 'get').toUpperCase(); + const url = normalizeUrl(config); + const params = stringifyParams(config.params); + + console.log( + `[API ${label}] ${method} ${url}${params ? ` params=${params}` : ''}`, + ); + + return config; + }); + + instance.interceptors.response.use( + (response) => { + const method = (response.config.method || 'get').toUpperCase(); + const url = normalizeUrl(response.config); + const data = stringifyData(response.data); + + console.log(`[API ${label}] ${method} ${url} -> ${response.status}`); + if (data) { + console.log(`[API ${label}] response=${data}`); + } + + return response; + }, + (error: AxiosError) => { + const config = error.config; + const method = config?.method?.toUpperCase() || 'UNKNOWN'; + const url = config ? normalizeUrl(config) : 'unknown'; + const status = error.response?.status; + const data = stringifyData(error.response?.data); + + console.log( + `[API ${label}] ${method} ${url} -> ERROR${status ? ` ${status}` : ''}`, + ); + if (data) { + console.log(`[API ${label}] response=${data}`); + } + + return Promise.reject(error); + }, + ); +}; diff --git a/src/api/client/axios.server.ts b/src/api/client/axios.server.ts index fd441ccd..7ad28f37 100644 --- a/src/api/client/axios.server.ts +++ b/src/api/client/axios.server.ts @@ -1,4 +1,5 @@ import axios, { InternalAxiosRequestConfig } from 'axios'; +import { attachApiLogger } from './api-logger'; import { getServerCookie } from '../../utils/server-cookie'; // * server-side axios 인스턴스 @@ -13,6 +14,8 @@ export const axiosServerInstance = axios.create({ withCredentials: true, }); +attachApiLogger(axiosServerInstance, 'server-json'); + const onRequestServer = async (config: InternalAxiosRequestConfig) => { const accessToken = await getServerCookie('accessToken'); diff --git a/src/api/client/axios.ts b/src/api/client/axios.ts index 5b9da700..d20b8ad5 100644 --- a/src/api/client/axios.ts +++ b/src/api/client/axios.ts @@ -1,5 +1,6 @@ import axios, { InternalAxiosRequestConfig, isAxiosError } from 'axios'; import { ApiError, isApiError } from './api-error'; +import { attachApiLogger } from './api-logger'; import { getCookie, setCookie } from './cookie'; // * client-side axios 인스턴스 @@ -23,6 +24,9 @@ export const axiosInstanceForMultipart = axios.create({ }, }); +attachApiLogger(axiosInstance, 'client-json'); +attachApiLogger(axiosInstanceForMultipart, 'client-multipart'); + const onRequestClient = (config: InternalAxiosRequestConfig) => { const accessToken = getCookie('accessToken'); diff --git a/src/api/client/axiosV2.server.ts b/src/api/client/axiosV2.server.ts index 33cf8bf7..f5670125 100644 --- a/src/api/client/axiosV2.server.ts +++ b/src/api/client/axiosV2.server.ts @@ -1,4 +1,5 @@ import axios, { InternalAxiosRequestConfig } from 'axios'; +import { attachApiLogger } from './api-logger'; import { getServerCookie } from '../../utils/server-cookie'; // * server-side axios 인스턴스 @@ -13,6 +14,8 @@ export const axiosServerInstanceV2 = axios.create({ withCredentials: true, }); +attachApiLogger(axiosServerInstanceV2, 'server-v2-json'); + const onRequestServer = async (config: InternalAxiosRequestConfig) => { const accessToken = await getServerCookie('accessToken'); diff --git a/src/api/client/axiosV2.ts b/src/api/client/axiosV2.ts index 8e1fd9d7..9b227e08 100644 --- a/src/api/client/axiosV2.ts +++ b/src/api/client/axiosV2.ts @@ -1,5 +1,6 @@ import axios, { InternalAxiosRequestConfig, isAxiosError } from 'axios'; import { ApiError, isApiError } from './api-error'; +import { attachApiLogger } from './api-logger'; import { getCookie, setCookie } from './cookie'; // * client-side axios 인스턴스 - openapi에 사용될 용도 (openapi로 전환 완료하면 axiosInstance로 변경) @@ -23,6 +24,9 @@ export const axiosInstanceForMultipartV2 = axios.create({ }, }); +attachApiLogger(axiosInstanceV2, 'client-v2-json'); +attachApiLogger(axiosInstanceForMultipartV2, 'client-v2-multipart'); + const onRequestClient = (config: InternalAxiosRequestConfig) => { const accessToken = getCookie('accessToken'); diff --git a/src/app/(service)/home/page.tsx b/src/app/(service)/home/page.tsx index b54d5980..41451263 100644 --- a/src/app/(service)/home/page.tsx +++ b/src/app/(service)/home/page.tsx @@ -28,6 +28,7 @@ export default async function Home({ +
); } diff --git a/src/app/(service)/insights/page.tsx b/src/app/(service)/insights/page.tsx index 30799732..c996bc7c 100644 --- a/src/app/(service)/insights/page.tsx +++ b/src/app/(service)/insights/page.tsx @@ -92,17 +92,6 @@ export default async function BlogPage({ searchParams }: BlogPageProps) { {category.name} ))} - - - 위클리 - - NEW - - - {/* 아티클 목록 */} diff --git a/src/app/(service)/insights/weekly/[id]/page.tsx b/src/app/(service)/insights/weekly/[id]/page.tsx deleted file mode 100644 index 24c45459..00000000 --- a/src/app/(service)/insights/weekly/[id]/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import VotingDetailPageClient from '@/features/study/one-to-one/balance-game/ui/voting-detail-page-client'; - -export default async function VotingDetailPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - const votingId = Number(id); - - return ; -} diff --git a/src/app/(service)/insights/weekly/page.tsx b/src/app/(service)/insights/weekly/page.tsx deleted file mode 100644 index 0b380169..00000000 --- a/src/app/(service)/insights/weekly/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import BalanceGamePage from '@/features/study/one-to-one/balance-game/ui/balance-game-page'; - -export default function VotingPage() { - return ; -} diff --git a/src/components/card/voting-card.tsx b/src/components/card/voting-card.tsx index 365e2eb1..42224390 100644 --- a/src/components/card/voting-card.tsx +++ b/src/components/card/voting-card.tsx @@ -1,5 +1,4 @@ import { MessageCircle, Users } from 'lucide-react'; -import Link from 'next/link'; import React from 'react'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; import UserAvatar from '@/components/ui/avatar'; @@ -10,12 +9,23 @@ import VoteTimer from '../voting/vote-timer'; interface VotingCardProps { voting: BalanceGame; onClick?: () => void; + onTagClick?: (tag: string) => void; } -export default function VotingCard({ voting, onClick }: VotingCardProps) { +export default function VotingCard({ + voting, + onClick, + onTagClick, +}: VotingCardProps) { const topOption = voting.options.reduce((prev, current) => prev.percentage > current.percentage ? prev : current, ); + const isActive = voting.isActive ?? true; + + const authorImage = + typeof voting.author.profileImage === 'string' + ? voting.author.profileImage + : voting.author.profileImage?.resizedImages?.[0]?.resizedImageUrl; // myVote can be null or number (optionId) const hasVoted = voting.myVote !== undefined && voting.myVote !== null; @@ -27,6 +37,7 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) { hasVoted ? 'ring-border-brand shadow-2' : 'ring-border-subtle hover:ring-border-brand hover:shadow-2', + !isActive && 'bg-background-default', )} onClick={ onClick @@ -48,7 +59,7 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) {
@@ -73,12 +84,18 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) { {voting.tags && Array.isArray(voting.tags) && voting.tags.length > 0 && (
{voting.tags.map((tag, index) => ( - { + event.preventDefault(); + event.stopPropagation(); + onTagClick?.(tag); + }} + className="rounded-100 bg-fill-neutral-subtle-default font-designer-12r text-text-subtle hover:ring-fill-brand-default-default px-150 py-50 ring-1 ring-transparent transition-shadow ring-inset" > #{tag} - + ))}
)} @@ -129,5 +146,5 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) { return cardContent; } - return {cardContent}; + return cardContent; } diff --git a/src/components/discussion/comment-list.tsx b/src/components/discussion/comment-list.tsx index b44eff35..3d0416e6 100644 --- a/src/components/discussion/comment-list.tsx +++ b/src/components/discussion/comment-list.tsx @@ -90,7 +90,10 @@ export default function CommentList({ 'avatar' in comment.author ? comment.author.avatar : 'profileImage' in comment.author - ? (comment.author as any).profileImage + ? typeof (comment.author as any).profileImage === 'string' + ? (comment.author as any).profileImage + : (comment.author as any).profileImage?.resizedImages?.[0] + ?.resizedImageUrl : undefined; return ( diff --git a/src/components/home/tab-navigation.tsx b/src/components/home/tab-navigation.tsx index 5f791d13..f651fac3 100644 --- a/src/components/home/tab-navigation.tsx +++ b/src/components/home/tab-navigation.tsx @@ -8,9 +8,12 @@ import { History, } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; import { getCookie } from '@/api/client/cookie'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import Button from '@/components/ui/button'; import { useAuth } from '@/hooks/common/use-auth'; +import { useScrollToHomeContent } from '@/hooks/use-scroll-to-home-content'; interface TabNavigationProps { activeTab: string; @@ -53,22 +56,40 @@ export default function TabNavigation({ activeTab }: TabNavigationProps) { const router = useRouter(); const searchParams = useSearchParams(); const { isAuthenticated } = useAuth(); - const hasMemberId = !!getCookie('memberId'); - const canViewHistory = isAuthenticated && hasMemberId; + const [canViewHistory, setCanViewHistory] = useState(false); const visibleTabs = canViewHistory ? TABS : TABS.filter((tab) => tab.id !== 'history'); + useEffect(() => { + const hasMemberId = !!getCookie('memberId'); + setCanViewHistory(isAuthenticated && hasMemberId); + }, [isAuthenticated]); + + const scrollToHomeContent = useScrollToHomeContent(); + const handleTabChange = (tabId: string) => { const params = new URLSearchParams(searchParams.toString()); params.set('tab', tabId); - router.push(`/home?${params.toString()}`); + router.push(`/home?${params.toString()}`, { scroll: false }); + requestAnimationFrame(scrollToHomeContent); + }; + + const handleStudyTutorial = () => { + const params = new URLSearchParams(searchParams.toString()); + params.set('tab', 'study'); + params.set('tutorial', 'study'); + router.push(`/home?${params.toString()}`, { scroll: false }); + requestAnimationFrame(scrollToHomeContent); }; return ( -
+

제로원 홈

+
diff --git a/src/components/tutorial/index.ts b/src/components/tutorial/index.ts new file mode 100644 index 00000000..09ac11ad --- /dev/null +++ b/src/components/tutorial/index.ts @@ -0,0 +1,2 @@ +export { default as TutorialOverlay } from './tutorial-overlay'; +export type { TutorialStep } from './tutorial-overlay'; diff --git a/src/components/tutorial/tutorial-overlay.tsx b/src/components/tutorial/tutorial-overlay.tsx new file mode 100644 index 00000000..57f39c0b --- /dev/null +++ b/src/components/tutorial/tutorial-overlay.tsx @@ -0,0 +1,480 @@ +'use client'; + +import React from 'react'; +import { createPortal } from 'react-dom'; + +export interface TutorialStep { + id: string; + title: string; + description: React.ReactNode; + targetSelector?: string; + targetRef?: React.RefObject; + placement?: 'top' | 'bottom' | 'left' | 'right'; + align?: 'start' | 'center' | 'end'; + spotlightPadding?: number; + spotlightRadius?: number; + scrollBlock?: ScrollLogicalPosition; +} + +interface TutorialOverlayProps { + open: boolean; + steps: TutorialStep[]; + activeIndex?: number; + onStepChange?: (nextIndex: number) => void; + onClose: () => void; + onFinish?: () => void; +} + +interface SpotlightRect { + left: number; + top: number; + width: number; + height: number; +} + +interface TooltipRect { + width: number; + height: number; +} + +const DEFAULT_SPOTLIGHT_PADDING = 8; +const DEFAULT_SPOTLIGHT_RADIUS = 12; +const TOOLTIP_GAP = 12; +const VIEWPORT_MARGIN = 12; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +const getTargetElement = (step: TutorialStep) => { + if (typeof document === 'undefined') return null; + if (step.targetRef?.current) return step.targetRef.current; + if (!step.targetSelector) return null; + + return document.querySelector(step.targetSelector) as HTMLElement | null; +}; + +const getSpotlightRect = ( + targetRect: DOMRect, + padding: number, +): SpotlightRect => { + const left = Math.max(0, targetRect.left - padding); + const top = Math.max(0, targetRect.top - padding); + const width = targetRect.width + padding * 2; + const height = targetRect.height + padding * 2; + + return { left, top, width, height }; +}; + +const getTooltipPosition = ({ + placement, + align, + targetRect, + tooltipRect, +}: { + placement: 'top' | 'bottom' | 'left' | 'right'; + align: 'start' | 'center' | 'end'; + targetRect: DOMRect | null; + tooltipRect: TooltipRect | null; +}) => { + if (typeof window === 'undefined') { + return { + left: 0, + top: 0, + transform: 'translate(0, 0)', + } as const; + } + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (!targetRect || !tooltipRect) { + return { + left: viewportWidth / 2, + top: viewportHeight / 2, + transform: 'translate(-50%, -50%)', + } as const; + } + + let left = targetRect.left; + let top = targetRect.top; + + if (placement === 'top') { + top = targetRect.top - tooltipRect.height - TOOLTIP_GAP; + } + if (placement === 'bottom') { + top = targetRect.bottom + TOOLTIP_GAP; + } + if (placement === 'left') { + left = targetRect.left - tooltipRect.width - TOOLTIP_GAP; + } + if (placement === 'right') { + left = targetRect.right + TOOLTIP_GAP; + } + + if (placement === 'top' || placement === 'bottom') { + if (align === 'center') { + left = targetRect.left + targetRect.width / 2 - tooltipRect.width / 2; + } + if (align === 'end') { + left = targetRect.right - tooltipRect.width; + } + } else { + if (align === 'center') { + top = targetRect.top + targetRect.height / 2 - tooltipRect.height / 2; + } + if (align === 'end') { + top = targetRect.bottom - tooltipRect.height; + } + } + + left = clamp( + left, + VIEWPORT_MARGIN, + viewportWidth - tooltipRect.width - VIEWPORT_MARGIN, + ); + top = clamp( + top, + VIEWPORT_MARGIN, + viewportHeight - tooltipRect.height - VIEWPORT_MARGIN, + ); + + return { left, top, transform: 'none' } as const; +}; + +export default function TutorialOverlay({ + open, + steps, + activeIndex: activeIndexProp, + onStepChange, + onClose, + onFinish, +}: TutorialOverlayProps) { + const [activeIndex, setActiveIndex] = React.useState(0); + const [spotlightRect, setSpotlightRect] = + React.useState(null); + const [tooltipRect, setTooltipRect] = React.useState( + null, + ); + const tooltipRef = React.useRef(null); + const frameRef = React.useRef(null); + const [mounted, setMounted] = React.useState(false); + const [recheckKey, setRecheckKey] = React.useState(0); + const missingCountRef = React.useRef(0); + + const isControlled = typeof activeIndexProp === 'number'; + const resolvedActiveIndex = isControlled ? activeIndexProp : activeIndex; + + const setStepIndex = React.useCallback( + (nextIndex: number) => { + if (isControlled) { + onStepChange?.(nextIndex); + } else { + setActiveIndex(nextIndex); + } + }, + [isControlled, onStepChange], + ); + + React.useEffect(() => { + setMounted(true); + }, []); + + React.useEffect(() => { + if (!open) return; + if (!isControlled) { + setActiveIndex(0); + } + setTooltipRect(null); + missingCountRef.current = 0; + }, [open, isControlled]); + + const requestClose = React.useCallback(() => { + onClose(); + }, [onClose]); + + const requestFinish = React.useCallback(() => { + onFinish?.(); + onClose(); + }, [onFinish, onClose]); + + const goNext = React.useCallback(() => { + if (resolvedActiveIndex >= steps.length - 1) { + requestFinish(); + + return; + } + setStepIndex(resolvedActiveIndex + 1); + }, [resolvedActiveIndex, steps.length, requestFinish, setStepIndex]); + + const goPrev = React.useCallback(() => { + setStepIndex(Math.max(0, resolvedActiveIndex - 1)); + }, [resolvedActiveIndex, setStepIndex]); + + const scheduleSpotlightUpdate = React.useCallback(() => { + if (frameRef.current) return; + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + const step = steps[resolvedActiveIndex]; + const target = step ? getTargetElement(step) : null; + if (!step || !target) { + setSpotlightRect(null); + + return; + } + const rect = target.getBoundingClientRect(); + setSpotlightRect( + getSpotlightRect( + rect, + step.spotlightPadding ?? DEFAULT_SPOTLIGHT_PADDING, + ), + ); + }); + }, [resolvedActiveIndex, steps]); + + React.useEffect(() => { + if (!open) return; + + const step = steps[resolvedActiveIndex]; + if (!step) return; + + const target = getTargetElement(step); + if (!target) { + missingCountRef.current += 1; + if (missingCountRef.current <= 20) { + const id = window.setTimeout(() => { + setRecheckKey((prev) => prev + 1); + }, 50); + + return () => window.clearTimeout(id); + } + missingCountRef.current = 0; + + return; + } + missingCountRef.current = 0; + + if (typeof window !== 'undefined') { + target.scrollIntoView({ + behavior: 'smooth', + block: step.scrollBlock ?? 'center', + inline: 'nearest', + }); + } + + scheduleSpotlightUpdate(); + + const handleScroll = () => scheduleSpotlightUpdate(); + const handleResize = () => scheduleSpotlightUpdate(); + + window.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleResize); + }; + }, [ + open, + resolvedActiveIndex, + scheduleSpotlightUpdate, + steps, + recheckKey, + requestClose, + setStepIndex, + ]); + + React.useEffect(() => { + if (!open) return; + if (!tooltipRef.current) return; + const id = window.requestAnimationFrame(() => { + if (!tooltipRef.current) return; + const rect = tooltipRef.current.getBoundingClientRect(); + setTooltipRect({ width: rect.width, height: rect.height }); + }); + + return () => window.cancelAnimationFrame(id); + }, [open, resolvedActiveIndex, spotlightRect]); + + React.useEffect(() => { + if (!open) return; + const handleKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + requestClose(); + } + if (event.key === 'ArrowRight') { + event.preventDefault(); + goNext(); + } + if (event.key === 'ArrowLeft') { + event.preventDefault(); + goPrev(); + } + }; + + window.addEventListener('keydown', handleKey); + + return () => window.removeEventListener('keydown', handleKey); + }, [open, goNext, goPrev, requestClose]); + + const currentStep = steps[resolvedActiveIndex]; + const tooltipPosition = React.useMemo(() => { + if (!open || !currentStep) return null; + const target = getTargetElement(currentStep); + const targetRect = target?.getBoundingClientRect() ?? null; + + return getTooltipPosition({ + placement: currentStep.placement ?? 'bottom', + align: currentStep.align ?? 'center', + targetRect, + tooltipRect, + }); + }, [open, currentStep, spotlightRect, tooltipRect]); + + if (!open || !mounted || !currentStep) return null; + + const overlay = ( +
+
+ + + + + {spotlightRect && ( + + )} + + + + {spotlightRect && ( + + )} + + +
+
+ {resolvedActiveIndex + 1} / {steps.length} +
+
+ {currentStep.title} +
+
+ {currentStep.description} +
+ +
+
+ {resolvedActiveIndex > 0 && ( + + )} + +
+
+
+
+ ); + + return createPortal(overlay, document.body); +} diff --git a/src/components/ui/action-pill-button.tsx b/src/components/ui/action-pill-button.tsx new file mode 100644 index 00000000..270733c4 --- /dev/null +++ b/src/components/ui/action-pill-button.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; + +type ActionPillVariant = 'primary' | 'neutral' | 'ghost'; +type ActionPillSize = 'xs' | 'sm' | 'md'; + +interface ActionPillButtonProps extends React.ButtonHTMLAttributes { + variant?: ActionPillVariant; + size?: ActionPillSize; + icon?: React.ReactNode; +} + +const VARIANT_CLASSES: Record = { + primary: + 'bg-fill-brand-default-default text-text-inverse hover:bg-fill-brand-default-hover', + neutral: + 'bg-background-alternative text-text-subtle hover:bg-fill-neutral-subtle-hover', + ghost: + 'bg-background-default text-text-subtle hover:bg-fill-neutral-subtle-hover', +}; + +const SIZE_CLASSES: Record = { + xs: 'px-100 py-50 font-designer-11m', + sm: 'px-150 py-50 font-designer-12m', + md: 'px-200 py-100 font-designer-12m', +}; + +export default function ActionPillButton({ + variant = 'ghost', + size = 'sm', + icon, + className, + children, + ...props +}: ActionPillButtonProps) { + return ( + + ); +} diff --git a/src/components/ui/filters/filter-pill-button.tsx b/src/components/ui/filters/filter-pill-button.tsx new file mode 100644 index 00000000..c2e66634 --- /dev/null +++ b/src/components/ui/filters/filter-pill-button.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; + +interface FilterPillButtonProps { + isActive: boolean; + onClick: () => void; + children: React.ReactNode; + disabled?: boolean; + className?: string; +} + +export default function FilterPillButton({ + isActive, + onClick, + children, + disabled = false, + className, +}: FilterPillButtonProps) { + return ( + + ); +} diff --git a/src/components/ui/filters/sort-dropdown.tsx b/src/components/ui/filters/sort-dropdown.tsx new file mode 100644 index 00000000..6e511205 --- /dev/null +++ b/src/components/ui/filters/sort-dropdown.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; + +export interface SortOption { + value: T; + label: string; +} + +interface SortDropdownProps { + value: T; + options: readonly SortOption[]; + onChange: (value: T) => void; + icon?: React.ReactNode; + className?: string; + menuClassName?: string; +} + +export default function SortDropdown({ + value, + options, + onChange, + icon, + className, + menuClassName, +}: SortDropdownProps) { + const label = options.find((option) => option.value === value)?.label ?? ''; + + return ( +
+ +
+
+ {options.map((option) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/ui/filters/view-mode-toggle.tsx b/src/components/ui/filters/view-mode-toggle.tsx new file mode 100644 index 00000000..71ebc704 --- /dev/null +++ b/src/components/ui/filters/view-mode-toggle.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; + +export interface ViewModeOption { + value: T; + icon: React.ReactNode; + title?: string; +} + +interface ViewModeToggleProps { + value: T; + options: ViewModeOption[]; + onChange: (value: T) => void; + className?: string; +} + +export default function ViewModeToggle({ + value, + options, + onChange, + className, +}: ViewModeToggleProps) { + return ( +
+ {options.map((option) => { + const isActive = option.value === value; + + return ( + + ); + })} +
+ ); +} diff --git a/src/components/ui/inline-section-header.tsx b/src/components/ui/inline-section-header.tsx new file mode 100644 index 00000000..cbebd614 --- /dev/null +++ b/src/components/ui/inline-section-header.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; + +interface InlineSectionHeaderProps { + title: React.ReactNode; + icon?: React.ReactNode; + badge?: React.ReactNode; + rightSlot?: React.ReactNode; + className?: string; + titleClassName?: string; +} + +export default function InlineSectionHeader({ + title, + icon, + badge, + rightSlot, + className, + titleClassName, +}: InlineSectionHeaderProps) { + return ( +
+
+ {icon} + + {title} + + {badge} +
+ {rightSlot} +
+ ); +} diff --git a/src/components/ui/input/base.tsx b/src/components/ui/input/base.tsx index aa34f505..13c04462 100644 --- a/src/components/ui/input/base.tsx +++ b/src/components/ui/input/base.tsx @@ -71,10 +71,11 @@ export const BaseInput = React.forwardRef( }, ref, ) => { + const isControlled = value !== undefined; const current = value ?? ''; const handleChange = (e: React.ChangeEvent) => { - if (e.target.value.length > maxLength) { + if (typeof maxLength === 'number' && e.target.value.length > maxLength) { e.target.value = e.target.value.slice(0, maxLength); } onChange?.(e); @@ -96,7 +97,7 @@ export const BaseInput = React.forwardRef( className, )} {...props} - value={current} + {...(isControlled ? { value: current } : {})} onChange={handleChange} /> {!hideMeta && ( diff --git a/src/components/ui/modal-shell.tsx b/src/components/ui/modal-shell.tsx new file mode 100644 index 00000000..5aa004fc --- /dev/null +++ b/src/components/ui/modal-shell.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { XIcon } from 'lucide-react'; +import React from 'react'; +import { Modal } from '@/components/ui/modal'; + +interface ModalShellProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + trigger?: React.ReactNode; + children: React.ReactNode; + footer?: React.ReactNode; +} + +export default function ModalShell({ + open, + onOpenChange, + title, + trigger, + children, + footer, +}: ModalShellProps) { + return ( + + {trigger ? {trigger} : null} + + + + + {title} + + + + + {children} + {footer ? {footer} : null} + + + + ); +} diff --git a/src/components/ui/section-header.tsx b/src/components/ui/section-header.tsx new file mode 100644 index 00000000..99879d68 --- /dev/null +++ b/src/components/ui/section-header.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; + +interface SectionHeaderProps { + title: string; + description?: React.ReactNode; + icon?: React.ReactNode; + rightSlot?: React.ReactNode; + className?: string; + titleClassName?: string; + descriptionClassName?: string; +} + +export default function SectionHeader({ + title, + description, + icon, + rightSlot, + className, + titleClassName, + descriptionClassName, +}: SectionHeaderProps) { + return ( +
+
+

+ {title} + {icon} +

+ {rightSlot} +
+ {description && ( +
+ {description} +
+ )} +
+ ); +} diff --git a/src/components/ui/section-shell.tsx b/src/components/ui/section-shell.tsx new file mode 100644 index 00000000..498e3c4c --- /dev/null +++ b/src/components/ui/section-shell.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; + +interface SectionShellProps { + children: React.ReactNode; + className?: string; +} + +export default function SectionShell({ + children, + className, +}: SectionShellProps) { + return ( +
{children}
+ ); +} diff --git a/src/components/ui/stat-item.tsx b/src/components/ui/stat-item.tsx new file mode 100644 index 00000000..5c353792 --- /dev/null +++ b/src/components/ui/stat-item.tsx @@ -0,0 +1,53 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; + +interface StatItemProps { + icon: React.ReactNode; + value: React.ReactNode; + onClick?: (event: React.MouseEvent) => void; + className?: string; + iconClassName?: string; + valueClassName?: string; + hoverClassName?: string; +} + +export default function StatItem({ + icon, + value, + onClick, + className, + iconClassName, + valueClassName, + hoverClassName, +}: StatItemProps) { + if (onClick) { + return ( + + ); + } + + return ( +
+ {icon} + {value} +
+ ); +} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 3c231840..c9df3a04 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -32,9 +32,9 @@ export default function Toast({ return (
void; @@ -21,6 +22,7 @@ const Tooltip: React.FC = ({ side = 'top', align = 'center', sideOffset = 5, + delayDuration, open, defaultOpen, onOpenChange, @@ -28,7 +30,7 @@ const Tooltip: React.FC = ({ arrowClassName, }) => { return ( - + ( - endsAt ? calculateTimeLeft(endsAt) : null, - ); + // 초기값을 null로 설정하여 서버와 클라이언트가 동일한 HTML을 렌더링하도록 함 + const [timeLeft, setTimeLeft] = useState(null); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + // 클라이언트에서만 마운트되었음을 표시 + setIsMounted(true); + }, []); useEffect(() => { - if (!endsAt || !isActive) return; + if (!endsAt || !isActive || !isMounted) return; + + // 초기 시간 계산 + const initialTimeLeft = calculateTimeLeft(endsAt); + setTimeLeft(initialTimeLeft); + + if (!initialTimeLeft) return; const timer = setInterval(() => { const newTimeLeft = calculateTimeLeft(endsAt); @@ -46,11 +57,11 @@ export default function VoteTimer({ endsAt, isActive }: VoteTimerProps) { }, 1000); return () => clearInterval(timer); - }, [endsAt, isActive]); + }, [endsAt, isActive, isMounted]); if (!isActive) { return ( -
+
종료된 투표
@@ -66,11 +77,22 @@ export default function VoteTimer({ endsAt, isActive }: VoteTimerProps) { ); } - if (!timeLeft) { + // 서버와 클라이언트가 동일한 HTML을 렌더링하도록 보장 + // 마운트되지 않았거나 timeLeft가 계산되지 않았을 때는 placeholder 표시 + if (!isMounted || !timeLeft) { return (
- - 종료 + +
+ 남은 시간 + + 00 + : + 00 + : + 00 + +
); } diff --git a/src/components/voting/voting-create-modal.tsx b/src/components/voting/voting-create-modal.tsx index 250e9e50..7cd6e676 100644 --- a/src/components/voting/voting-create-modal.tsx +++ b/src/components/voting/voting-create-modal.tsx @@ -1,15 +1,27 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { X, Plus, Trash2, Loader2 } from 'lucide-react'; import React, { useState } from 'react'; -import { useForm, useFieldArray } from 'react-hook-form'; -import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import { FormProvider, useForm, useFieldArray } from 'react-hook-form'; +import FormField from '@/components/ui/form/form-field'; +import { BaseInput, TextAreaInput } from '@/components/ui/input'; import { Modal } from '@/components/ui/modal'; +import { + BALANCE_GAME_TAG_MAX_COUNT, + BALANCE_GAME_TAG_MAX_LEN, + BALANCE_GAME_TAG_MIN_QUERY_LEN, +} from '@/features/study/one-to-one/balance-game/const/tags'; +import { useBalanceGameTagSuggestionsQuery } from '@/features/study/one-to-one/balance-game/model/use-balance-game-query'; +import { useDebounce } from '@/hooks/use-debounce'; import { VotingCreateFormSchema, VotingCreateFormData, } from '@/types/schemas/zod-schema'; +import VotingDeadlineField from './voting-deadline-field'; +import VotingModalFooter from './voting-modal-footer'; +import VotingModalHeader from './voting-modal-header'; +import VotingOptionFields from './voting-option-fields'; +import VotingTagField from './voting-tag-field'; interface VotingCreateModalProps { isOpen: boolean; @@ -22,19 +34,35 @@ export default function VotingCreateModal({ onClose, onSubmit, }: VotingCreateModalProps) { + return ( + { + if (!nextOpen) onClose(); + }} + > + + + + + + + + ); +} + +interface VotingCreateFormProps { + onClose: () => void; + onSubmit: (data: VotingCreateFormData) => Promise; +} + +function VotingCreateForm({ onClose, onSubmit }: VotingCreateFormProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [tagInput, setTagInput] = useState(''); - const { - register, - handleSubmit, - control, - watch, - setValue, - formState: { errors }, - reset, - } = useForm({ + const methods = useForm({ resolver: zodResolver(VotingCreateFormSchema), + mode: 'onChange', defaultValues: { title: '', description: '', @@ -44,47 +72,32 @@ export default function VotingCreateModal({ }, }); + const { register, handleSubmit, control, watch, setValue, reset, formState } = + methods; + const { fields, append, remove } = useFieldArray({ control, name: 'options', }); const watchedTags = watch('tags') || []; - const watchedTitle = watch('title') || ''; - const watchedDescription = watch('description') || ''; - const watchedEndsAt = watch('endsAt') || ''; - // 날짜만 선택하고 시간은 23:59로 고정하는 핸들러 - const handleDateChange = (e: React.ChangeEvent) => { - const selectedDate = e.target.value; - if (selectedDate) { - // 선택한 날짜의 23:59로 설정 - const dateTimeString = `${selectedDate}T23:59`; - setValue('endsAt', dateTimeString); - } else { - setValue('endsAt', ''); - } - }; - - // 날짜만 추출 (표시용) - const selectedDateOnly = watchedEndsAt ? watchedEndsAt.split('T')[0] : ''; - - // 오늘 날짜를 YYYY-MM-DD 형식으로 가져오기 - const getTodayDateString = () => { - const today = new Date(); - const year = today.getFullYear(); - const month = String(today.getMonth() + 1).padStart(2, '0'); - const day = String(today.getDate()).padStart(2, '0'); - - return `${year}-${month}-${day}`; - }; - - // 태그 추가 - const handleAddTag = () => { - const trimmedTag = tagInput.trim(); + const debouncedTagQuery = useDebounce(tagInput, 300); + const trimmedTagQuery = debouncedTagQuery.trim(); + const { data: tagSuggestions = [], isFetching: isTagLoading } = + useBalanceGameTagSuggestionsQuery(trimmedTagQuery, { + limit: 10, + enabled: trimmedTagQuery.length >= BALANCE_GAME_TAG_MIN_QUERY_LEN, + minLength: BALANCE_GAME_TAG_MIN_QUERY_LEN, + sort: 'popular', + }); + + const handleAddTag = (value: string) => { + const trimmedTag = value.trim(); if ( trimmedTag && - watchedTags.length < 3 && + trimmedTag.length <= BALANCE_GAME_TAG_MAX_LEN && + watchedTags.length < BALANCE_GAME_TAG_MAX_COUNT && !watchedTags.includes(trimmedTag) ) { setValue('tags', [...watchedTags, trimmedTag]); @@ -92,7 +105,6 @@ export default function VotingCreateModal({ } }; - // 태그 삭제 const handleRemoveTag = (tagToRemove: string) => { setValue( 'tags', @@ -100,11 +112,9 @@ export default function VotingCreateModal({ ); }; - // 폼 제출 const handleFormSubmit = async (data: VotingCreateFormData) => { setIsSubmitting(true); try { - // endsAt이 빈 문자열이면 undefined로 변환 const submitData = { ...data, endsAt: @@ -112,6 +122,7 @@ export default function VotingCreateModal({ }; await onSubmit(submitData); reset(); + setTagInput(''); onClose(); } catch (error) { console.error('투표 생성 실패:', error); @@ -120,7 +131,6 @@ export default function VotingCreateModal({ } }; - // 모달 닫기 const handleClose = () => { if (!isSubmitting) { reset(); @@ -130,263 +140,70 @@ export default function VotingCreateModal({ }; return ( - { - if (!nextOpen) handleClose(); - }} - > - - - - - - 새 투표 주제 만들기 - - - - - - - -
- {/* 제목 */} -
- - -
- {errors.title && ( -

- {errors.title.message} -

- )} - - {watchedTitle.length}/200 - -
-
- - {/* 설명 */} -
- -