From a4b23f6469cb2cb355cd65ac073abf79fb6eadcd Mon Sep 17 00:00:00 2001 From: Choi Youngae Date: Sat, 7 Feb 2026 21:22:32 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20=EC=B1=85=20=ED=8F=89=EA=B0=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book/components/BookReviewForm.tsx | 296 ++++++++++++++++++ .../book/components/BookReviewModal.tsx | 283 +---------------- .../components/BookReviewSection.tsx | 58 ++++ .../components/PreOpinionQuestionSection.tsx | 7 + .../components/PreOpinionWriteHeader.tsx | 56 ++++ src/pages/PreOpinions/PreOpinionWritePage.tsx | 43 +++ src/routes/index.tsx | 5 + src/shared/assets/icon/FilledInfo.tsx | 27 ++ src/shared/ui/Container.tsx | 49 +-- 9 files changed, 537 insertions(+), 287 deletions(-) create mode 100644 src/features/book/components/BookReviewForm.tsx create mode 100644 src/features/pre-opinion/components/BookReviewSection.tsx create mode 100644 src/features/pre-opinion/components/PreOpinionQuestionSection.tsx create mode 100644 src/features/pre-opinion/components/PreOpinionWriteHeader.tsx create mode 100644 src/pages/PreOpinions/PreOpinionWritePage.tsx create mode 100644 src/shared/assets/icon/FilledInfo.tsx diff --git a/src/features/book/components/BookReviewForm.tsx b/src/features/book/components/BookReviewForm.tsx new file mode 100644 index 0000000..cdfb9b7 --- /dev/null +++ b/src/features/book/components/BookReviewForm.tsx @@ -0,0 +1,296 @@ +import { X } from 'lucide-react' +import { useMemo, useState } from 'react' + +import { useKeywords } from '@/features/keywords' +import { StarRate } from '@/shared/components/StarRate' +import { Chip } from '@/shared/ui/Chip' +import { Tabs, TabsList, TabsTrigger } from '@/shared/ui/Tabs' + +/** BookReviewForm의 현재 폼 상태 */ +export interface BookReviewFormValues { + rating: number + keywordIds: number[] + isValid: boolean +} + +/** + * 책 평가 폼 컴포넌트 + * + * @description 별점과 키워드를 선택하는 입력 UI만 담당합니다. + * 제출 버튼은 포함하지 않으며, onChange 콜백으로 현재 폼 상태를 전달합니다. + * + * @example + * ```tsx + * // 빈 폼 + * setFormValues(values)} /> + * + * // 기존 데이터가 있는 경우 + * setFormValues(values)} + * /> + * ``` + */ +export interface BookReviewFormProps { + /** 초기 별점 값 */ + initialRating?: number + /** 초기 선택된 키워드 ID 목록 */ + initialKeywordIds?: number[] + /** 폼 상태 변경 콜백 */ + onChange?: (values: BookReviewFormValues) => void +} + +export function BookReviewForm({ + initialRating = 0, + initialKeywordIds = [], + onChange, +}: BookReviewFormProps) { + const [rating, setRating] = useState(initialRating) + const [selectedKeywordIds, setSelectedKeywordIds] = useState(initialKeywordIds) + const [selectedBookCategoryId, setSelectedBookCategoryId] = useState(null) + const [selectedImpressionCategoryId, setSelectedImpressionCategoryId] = useState( + null + ) + + const { + data: keywordsData, + isLoading: isLoadingKeywords, + isError: isKeywordsError, + } = useKeywords() + + // 책 키워드 카테고리 (level 1) + const bookCategories = useMemo( + () => + keywordsData?.keywords.filter((k) => k.type === 'BOOK' && k.level === 1 && !k.isSelectable) || + [], + [keywordsData] + ) + + // 감정 키워드 카테고리 (level 1) + const impressionCategories = useMemo( + () => + keywordsData?.keywords.filter( + (k) => k.type === 'IMPRESSION' && k.level === 1 && !k.isSelectable + ) || [], + [keywordsData] + ) + + // 선택 가능한 책 키워드 (level 2) + const bookKeywords = useMemo(() => { + const allKeywords = + keywordsData?.keywords.filter((k) => k.type === 'BOOK' && k.isSelectable) || [] + + if (selectedBookCategoryId === null) { + return allKeywords + } + + return allKeywords.filter((k) => k.parentId === selectedBookCategoryId) + }, [keywordsData, selectedBookCategoryId]) + + // 선택 가능한 감정 키워드 (level 2) + const impressionKeywords = useMemo(() => { + const allKeywords = + keywordsData?.keywords.filter((k) => k.type === 'IMPRESSION' && k.isSelectable) || [] + + if (selectedImpressionCategoryId === null) { + return allKeywords + } + + return allKeywords.filter((k) => k.parentId === selectedImpressionCategoryId) + }, [keywordsData, selectedImpressionCategoryId]) + + // 선택된 책 키워드 + const selectedBookKeywords = useMemo(() => { + return ( + keywordsData?.keywords.filter( + (k) => k.type === 'BOOK' && selectedKeywordIds.includes(k.id) + ) || [] + ) + }, [keywordsData, selectedKeywordIds]) + + // 선택된 감정 키워드 + const selectedImpressionKeywords = useMemo(() => { + return ( + keywordsData?.keywords.filter( + (k) => k.type === 'IMPRESSION' && selectedKeywordIds.includes(k.id) + ) || [] + ) + }, [keywordsData, selectedKeywordIds]) + + const handleRatingChange = (newRating: number) => { + setRating(newRating) + onChange?.({ + rating: newRating, + keywordIds: selectedKeywordIds, + isValid: newRating > 0 && selectedBookKeywords.length > 0 && selectedImpressionKeywords.length > 0, + }) + } + + const handleKeywordToggle = (keywordId: number) => { + const nextIds = selectedKeywordIds.includes(keywordId) + ? selectedKeywordIds.filter((id) => id !== keywordId) + : [...selectedKeywordIds, keywordId] + setSelectedKeywordIds(nextIds) + + const nextBookCount = keywordsData?.keywords.filter( + (k) => k.type === 'BOOK' && nextIds.includes(k.id) + ).length ?? 0 + const nextImpressionCount = keywordsData?.keywords.filter( + (k) => k.type === 'IMPRESSION' && nextIds.includes(k.id) + ).length ?? 0 + + onChange?.({ + rating, + keywordIds: nextIds, + isValid: rating > 0 && nextBookCount > 0 && nextImpressionCount > 0, + }) + } + + if (isKeywordsError) { + return ( +
+

키워드를 불러오지 못했습니다. 다시 시도해주세요.

+
+ ) + } + + if (isLoadingKeywords) { + return ( +
+

키워드를 불러오는 중...

+
+ ) + } + + return ( + <> +
+ {/* 별점 */} +
+

별점

+
+ +

{rating.toFixed(1)}

+
+
+ + {/* 책 키워드 */} +
+

책 키워드

+ + {/* 카테고리 탭 */} + + setSelectedBookCategoryId(value === 'all' ? null : Number(value)) + } + className="mb-tiny ml-xtiny" + > + + 전체 + {bookCategories.map((category) => ( + + {category.name} + + ))} + + + + {/* 키워드 목록 */} +
+ {bookKeywords.map((keyword) => { + const isSelected = selectedKeywordIds.includes(keyword.id) + return ( + handleKeywordToggle(keyword.id)} + className="cursor-pointer" + > + {keyword.name} + + ) + })} +
+ + {/* 선택한 키워드 */} + {selectedBookKeywords.length > 0 && ( +
+

선택한 키워드

+
+ {selectedBookKeywords.map((keyword) => ( + handleKeywordToggle(keyword.id)} + className="cursor-pointer bg-white text-black" + > + {keyword.name} + + ))} +
+
+ )} +
+ + {/* 감정 키워드 */} +
+

감상 키워드

+ + {/* 카테고리 탭 */} + + setSelectedImpressionCategoryId(value === 'all' ? null : Number(value)) + } + className="mb-tiny ml-xtiny" + > + + 전체 + {impressionCategories.map((category) => ( + + {category.name} + + ))} + + + + {/* 키워드 목록 */} +
+ {impressionKeywords.map((keyword) => { + const isSelected = selectedKeywordIds.includes(keyword.id) + return ( + handleKeywordToggle(keyword.id)} + className="cursor-pointer" + > + {keyword.name} + + ) + })} +
+ + {/* 선택한 키워드 */} + {selectedImpressionKeywords.length > 0 && ( +
+

선택한 키워드

+
+ {selectedImpressionKeywords.map((keyword) => ( + handleKeywordToggle(keyword.id)} + className="cursor-pointer bg-white text-black" + > + {keyword.name} + + ))} +
+
+ )} +
+
+ + ) +} diff --git a/src/features/book/components/BookReviewModal.tsx b/src/features/book/components/BookReviewModal.tsx index 4a9f182..b796ebf 100644 --- a/src/features/book/components/BookReviewModal.tsx +++ b/src/features/book/components/BookReviewModal.tsx @@ -1,10 +1,6 @@ -import { X } from 'lucide-react' -import { useMemo, useState } from 'react' +import { useState } from 'react' -import { useKeywords } from '@/features/keywords' -import { StarRate } from '@/shared/components/StarRate' import { Button } from '@/shared/ui/Button' -import { Chip } from '@/shared/ui/Chip' import { Modal, ModalBody, @@ -13,10 +9,10 @@ import { ModalHeader, ModalTitle, } from '@/shared/ui/Modal' -import { Tabs, TabsList, TabsTrigger } from '@/shared/ui/Tabs' import { useGlobalModalStore } from '@/store' import { useCreateBookReview } from '../hooks' +import { BookReviewForm, type BookReviewFormValues } from './BookReviewForm' /** * 책 평가하기 모달 @@ -39,117 +35,25 @@ export interface BookReviewModalProps { } export function BookReviewModal({ bookId, open, onOpenChange }: BookReviewModalProps) { - const [rating, setRating] = useState(0) - const [selectedKeywordIds, setSelectedKeywordIds] = useState([]) - const [selectedBookCategoryId, setSelectedBookCategoryId] = useState(null) - const [selectedImpressionCategoryId, setSelectedImpressionCategoryId] = useState( - null - ) - - const { - data: keywordsData, - isLoading: isLoadingKeywords, - isError: isKeywordsError, - } = useKeywords() + const [formValues, setFormValues] = useState({ + rating: 0, + keywordIds: [], + isValid: false, + }) const { mutate: submitReview, isPending } = useCreateBookReview(bookId) const { openError } = useGlobalModalStore() - const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen) { - setRating(0) - setSelectedKeywordIds([]) - setSelectedBookCategoryId(null) - setSelectedImpressionCategoryId(null) - } - onOpenChange(nextOpen) - } - - // 책 키워드 카테고리 (level 1) - const bookCategories = useMemo( - () => - keywordsData?.keywords.filter((k) => k.type === 'BOOK' && k.level === 1 && !k.isSelectable) || - [], - [keywordsData] - ) - - // 감정 키워드 카테고리 (level 1) - const impressionCategories = useMemo( - () => - keywordsData?.keywords.filter( - (k) => k.type === 'IMPRESSION' && k.level === 1 && !k.isSelectable - ) || [], - [keywordsData] - ) - - // 선택 가능한 책 키워드 (level 2) - const bookKeywords = useMemo(() => { - const allKeywords = - keywordsData?.keywords.filter((k) => k.type === 'BOOK' && k.isSelectable) || [] - - if (selectedBookCategoryId === null) { - return allKeywords - } - - return allKeywords.filter((k) => k.parentId === selectedBookCategoryId) - }, [keywordsData, selectedBookCategoryId]) - - // 선택 가능한 감정 키워드 (level 2) - const impressionKeywords = useMemo(() => { - const allKeywords = - keywordsData?.keywords.filter((k) => k.type === 'IMPRESSION' && k.isSelectable) || [] - - if (selectedImpressionCategoryId === null) { - return allKeywords - } - - return allKeywords.filter((k) => k.parentId === selectedImpressionCategoryId) - }, [keywordsData, selectedImpressionCategoryId]) - - // 선택된 책 키워드 - const selectedBookKeywords = useMemo(() => { - return ( - keywordsData?.keywords.filter( - (k) => k.type === 'BOOK' && selectedKeywordIds.includes(k.id) - ) || [] - ) - }, [keywordsData, selectedKeywordIds]) - - // 선택된 감정 키워드 - const selectedImpressionKeywords = useMemo(() => { - return ( - keywordsData?.keywords.filter( - (k) => k.type === 'IMPRESSION' && selectedKeywordIds.includes(k.id) - ) || [] - ) - }, [keywordsData, selectedKeywordIds]) - - // 저장 버튼 활성화 조건 - const isFormValid = useMemo(() => { - return rating > 0 && selectedBookKeywords.length > 0 && selectedImpressionKeywords.length > 0 - }, [rating, selectedBookKeywords.length, selectedImpressionKeywords.length]) - - const handleKeywordToggle = (keywordId: number) => { - setSelectedKeywordIds((prev) => - prev.includes(keywordId) ? prev.filter((id) => id !== keywordId) : [...prev, keywordId] - ) - } - const handleSubmit = () => { - if (rating === 0) { + if (formValues.rating === 0) { openError('별점 필요', '별점을 선택해주세요.') return } submitReview( - { rating, keywordIds: selectedKeywordIds }, + { rating: formValues.rating, keywordIds: formValues.keywordIds }, { onSuccess: () => { onOpenChange(false) - // 상태 초기화 - setRating(0) - setSelectedKeywordIds([]) - setSelectedBookCategoryId(null) - setSelectedImpressionCategoryId(null) }, onError: (error) => { openError('평가 저장 실패', error.message) @@ -158,42 +62,6 @@ export function BookReviewModal({ bookId, open, onOpenChange }: BookReviewModalP ) } - if (isKeywordsError) { - return ( - - - - 책 평가하기 - - -
-

- 키워드를 불러오지 못했습니다. 다시 시도해주세요. -

-
-
-
-
- ) - } - - if (isLoadingKeywords) { - return ( - - - - 책 평가하기 - - -
-

키워드를 불러오는 중...

-
-
-
-
- ) - } - return ( @@ -201,135 +69,14 @@ export function BookReviewModal({ bookId, open, onOpenChange }: BookReviewModalP 책 평가하기 -
- {/* 별점 */} -
-

별점

-
- -

{rating.toFixed(1)}

-
-
- - {/* 책 키워드 */} -
-

책 키워드

- - {/* 카테고리 탭 */} - - setSelectedBookCategoryId(value === 'all' ? null : Number(value)) - } - className="mb-tiny ml-xtiny" - > - - 전체 - {bookCategories.map((category) => ( - - {category.name} - - ))} - - - - {/* 키워드 목록 */} -
- {bookKeywords.map((keyword) => { - const isSelected = selectedKeywordIds.includes(keyword.id) - return ( - handleKeywordToggle(keyword.id)} - className="cursor-pointer" - > - {keyword.name} - - ) - })} -
- - {/* 선택한 키워드 */} - {selectedBookKeywords.length > 0 && ( -
-

선택한 키워드

-
- {selectedBookKeywords.map((keyword) => ( - handleKeywordToggle(keyword.id)} - className="cursor-pointer bg-white text-black" - > - {keyword.name} - - ))} -
-
- )} -
- - {/* 감정 키워드 */} -
-

감상 키워드

- - {/* 카테고리 탭 */} - - setSelectedImpressionCategoryId(value === 'all' ? null : Number(value)) - } - className="mb-tiny ml-xtiny" - > - - 전체 - {impressionCategories.map((category) => ( - - {category.name} - - ))} - - - - {/* 키워드 목록 */} -
- {impressionKeywords.map((keyword) => { - const isSelected = selectedKeywordIds.includes(keyword.id) - return ( - handleKeywordToggle(keyword.id)} - className="cursor-pointer" - > - {keyword.name} - - ) - })} -
- - {/* 선택한 키워드 */} - {selectedImpressionKeywords.length > 0 && ( -
-

선택한 키워드

-
- {selectedImpressionKeywords.map((keyword) => ( - handleKeywordToggle(keyword.id)} - className="cursor-pointer bg-white text-black" - > - {keyword.name} - - ))} -
-
- )} -
-
+
- diff --git a/src/features/pre-opinion/components/BookReviewSection.tsx b/src/features/pre-opinion/components/BookReviewSection.tsx new file mode 100644 index 0000000..a27f103 --- /dev/null +++ b/src/features/pre-opinion/components/BookReviewSection.tsx @@ -0,0 +1,58 @@ +import { + BookReviewForm, + type BookReviewFormValues, +} from '@/features/book/components/BookReviewForm' +import { useBookReview } from '@/features/book/hooks' +import { Container } from '@/shared/ui' + +/** + * 사전 의견 작성 페이지의 책 평가 섹션 + * + * @description 기존 평가가 있으면 해당 데이터를 채워서 보여주고, + * 없으면 빈 폼을 보여줘서 사용자가 평가를 남길 수 있게 합니다. + * 제출은 상위 페이지에서 일괄 처리합니다. + * + * @example + * ```tsx + * setReviewValues(values)} /> + * ``` + */ +interface BookReviewSectionProps { + bookId: number + onChange?: (values: BookReviewFormValues) => void +} + +const BookReviewSection = ({ bookId, onChange }: BookReviewSectionProps) => { + const { data: review, isLoading } = useBookReview(bookId) + + if (isLoading) { + return ( +
+

책 평가

+
+

로딩 중...

+
+
+ ) + } + + return ( + + + 이 책은 어떠셨나요? + + + k.id) ?? []} + onChange={onChange} + /> + + + ) +} + +export default BookReviewSection diff --git a/src/features/pre-opinion/components/PreOpinionQuestionSection.tsx b/src/features/pre-opinion/components/PreOpinionQuestionSection.tsx new file mode 100644 index 0000000..69e79df --- /dev/null +++ b/src/features/pre-opinion/components/PreOpinionQuestionSection.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +const PreOpinionQuestionSection = () => { + return
PreOpinionQuestionSection
+} + +export default PreOpinionQuestionSection diff --git a/src/features/pre-opinion/components/PreOpinionWriteHeader.tsx b/src/features/pre-opinion/components/PreOpinionWriteHeader.tsx new file mode 100644 index 0000000..1ecdcdb --- /dev/null +++ b/src/features/pre-opinion/components/PreOpinionWriteHeader.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef, useState } from 'react' + +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui' + +const PreOpinionWriteHeader = () => { + const sentinelRef = useRef(null) + const [isStuck, setIsStuck] = useState(false) + + useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel) return + + const observer = new IntersectionObserver( + ([entry]) => { + setIsStuck(!entry.isIntersecting) + }, + { threshold: 0 } + ) + + observer.observe(sentinel) + return () => observer.disconnect() + }, []) + + return ( + <> +
+
+
+
+
+

사전 의견 작성하기

+

+ {'데미안'} · {'헤르만 헤세'} +

+
+
+

{'2025. 12.31 18:36 마지막 저장'}

+ + +
+
+
+
+ + ) +} + +export default PreOpinionWriteHeader diff --git a/src/pages/PreOpinions/PreOpinionWritePage.tsx b/src/pages/PreOpinions/PreOpinionWritePage.tsx new file mode 100644 index 0000000..77698f6 --- /dev/null +++ b/src/pages/PreOpinions/PreOpinionWritePage.tsx @@ -0,0 +1,43 @@ +import { useParams } from 'react-router-dom' + +import { useMeetingDetail } from '@/features/meetings' +import BookReviewSection from '@/features/pre-opinion/components/BookReviewSection' +import PreOpinionQuestionSection from '@/features/pre-opinion/components/PreOpinionQuestionSection' +import PreOpinionWriteHeader from '@/features/pre-opinion/components/PreOpinionWriteHeader' +import SubPageHeader from '@/shared/components/SubPageHeader' +import { Card } from '@/shared/ui' + +export default function PreOpinionWritePage() { + const { meetingId } = useParams<{ gatheringId: string; meetingId: string }>() + const { data: meeting, isLoading } = useMeetingDetail(Number(meetingId)) + + if (isLoading || !meeting) { + return ( + <> + +
+

로딩 중...

+
+ + ) + } + + return ( + <> + + + +
+
+ +

+ 작성하신 사전 의견은 약속 당일이 되면 멤버들에게 자동으로 공개돼요. +

+
+ + +
+
+ + ) +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 8d08899..b8d98a4 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -17,6 +17,7 @@ import { OnboardingPage, RecordListPage, } from '@/pages' +import PreOpinionWritePage from '@/pages/PreOpinions/PreOpinionWritePage' import { ROUTES } from '@/shared/constants' import { AuthLayout, MainLayout, RootLayout } from '@/shared/layout' @@ -100,6 +101,10 @@ export const router = createBrowserRouter([ path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId`, element: , }, + { + path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId/pre-opinions/new`, + element: , + }, { path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/setting`, element: , diff --git a/src/shared/assets/icon/FilledInfo.tsx b/src/shared/assets/icon/FilledInfo.tsx new file mode 100644 index 0000000..937f821 --- /dev/null +++ b/src/shared/assets/icon/FilledInfo.tsx @@ -0,0 +1,27 @@ +import { cn } from '@/shared/lib/utils' + +type Props = { + size?: number + className?: string +} + +export function FilledInfoIcon({ size = 24, className }: Props) { + return ( + + + + + + ) +} diff --git a/src/shared/ui/Container.tsx b/src/shared/ui/Container.tsx index 056a941..f7bf987 100644 --- a/src/shared/ui/Container.tsx +++ b/src/shared/ui/Container.tsx @@ -3,6 +3,8 @@ import * as React from 'react' import { cn } from '@/shared/lib/utils' +import { FilledInfoIcon } from '../assets/icon/FilledInfo' + type ContainerProps = { className?: string children?: React.ReactNode @@ -13,6 +15,7 @@ type TitleProps = { children: string required?: boolean errorMessage?: string + infoMessage?: string } type ContentProps = { @@ -37,27 +40,35 @@ const Container = React.forwardRef(function Cont ) }) -function Title({ className, children, required, errorMessage }: TitleProps) { +function Title({ className, children, required, errorMessage, infoMessage }: TitleProps) { return ( -
-
-

- {children} - {required && ( - - )} -

-
- {errorMessage && ( - - {errorMessage} - +
+ {infoMessage && ( +
+ + {infoMessage} +
)} +
+
+

+ {children} + {required && ( + + )} +

+
+ {errorMessage && ( + + {errorMessage} + + )} +
) } From 5b47ee258eb41f2f71fa98a2898df6b6d115ada4 Mon Sep 17 00:00:00 2001 From: Choi Youngae Date: Sat, 7 Feb 2026 23:19:24 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20=EC=82=AC=EC=A0=84=EC=9D=98?= =?UTF-8?q?=EA=B2=AC=20=EC=A3=BC=EC=A0=9C=20=EB=82=B4=EC=97=AD=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B5=AC=ED=98=84=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/BookReviewSection.tsx | 4 +- .../components/PreOpinionQuestionSection.tsx | 45 +++++++++++- .../components/PreOpinionWriteHeader.tsx | 33 +++++++-- src/features/pre-opinion/hooks/index.ts | 2 + .../pre-opinion/hooks/preOpinionQueryKeys.ts | 11 +++ .../pre-opinion/hooks/usePreOpinion.ts | 38 ++++++++++ src/features/pre-opinion/lib/date.tsx | 9 +++ src/features/pre-opinion/preOpinion.api.ts | 32 +++++++++ .../pre-opinion/preOpinion.endpoints.ts | 7 ++ src/features/pre-opinion/preOpinion.mock.ts | 50 +++++++++++++ src/features/pre-opinion/preOpinion.types.ts | 71 +++++++++++++++++++ src/pages/PreOpinions/PreOpinionWritePage.tsx | 13 ++-- src/shared/ui/Container.tsx | 4 +- 13 files changed, 305 insertions(+), 14 deletions(-) create mode 100644 src/features/pre-opinion/hooks/index.ts create mode 100644 src/features/pre-opinion/hooks/preOpinionQueryKeys.ts create mode 100644 src/features/pre-opinion/hooks/usePreOpinion.ts create mode 100644 src/features/pre-opinion/lib/date.tsx create mode 100644 src/features/pre-opinion/preOpinion.api.ts create mode 100644 src/features/pre-opinion/preOpinion.endpoints.ts create mode 100644 src/features/pre-opinion/preOpinion.mock.ts create mode 100644 src/features/pre-opinion/preOpinion.types.ts diff --git a/src/features/pre-opinion/components/BookReviewSection.tsx b/src/features/pre-opinion/components/BookReviewSection.tsx index a27f103..58724db 100644 --- a/src/features/pre-opinion/components/BookReviewSection.tsx +++ b/src/features/pre-opinion/components/BookReviewSection.tsx @@ -37,10 +37,10 @@ const BookReviewSection = ({ bookId, onChange }: BookReviewSectionProps) => { } return ( - + 이 책은 어떠셨나요? diff --git a/src/features/pre-opinion/components/PreOpinionQuestionSection.tsx b/src/features/pre-opinion/components/PreOpinionQuestionSection.tsx index 69e79df..ae00d9a 100644 --- a/src/features/pre-opinion/components/PreOpinionQuestionSection.tsx +++ b/src/features/pre-opinion/components/PreOpinionQuestionSection.tsx @@ -1,7 +1,46 @@ -import React from 'react' +import type { PreOpinionTopic } from '@/features/pre-opinion/preOpinion.types' +import { Badge, Container, Textarea } from '@/shared/ui' -const PreOpinionQuestionSection = () => { - return
PreOpinionQuestionSection
+interface PreOpinionQuestionSectionProps { + topics: PreOpinionTopic[] +} + +/** + * 사전 의견 주제별 질문 섹션 + * + * @description + * 확정된 주제 목록을 confirmOrder 순서대로 렌더링합니다. + * 각 주제는 Container 컴포넌트로 감싸며, + * 주제 설명과 텍스트 입력 영역을 포함합니다. + * + * @example + * ```tsx + * + * ``` + */ +const PreOpinionQuestionSection = ({ topics }: PreOpinionQuestionSectionProps) => { + const sortedTopics = [...topics].sort((a, b) => a.confirmOrder - b.confirmOrder) + + return ( + <> + {sortedTopics.map((topic) => ( + + {topic.topicTypeLabel}} + > + {`주제 ${topic.confirmOrder}. ${topic.topicTitle}`} + + +
+

{topic.topicDescription}

+