diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index 4988b80..e545287 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -11,6 +11,7 @@ export const API_PATHS = { AUTH: `${API_BASE}/auth`, USERS: `${API_BASE}/users`, BOOK: `${API_BASE}/book`, + KEYWORDS: `${API_BASE}/keywords`, GATHERINGS: `${API_BASE}/gatherings`, MEETINGS: `${API_BASE}/meetings`, } as const diff --git a/src/features/book/book.api.ts b/src/features/book/book.api.ts index a3c2eba..b6074c1 100644 --- a/src/features/book/book.api.ts +++ b/src/features/book/book.api.ts @@ -83,8 +83,8 @@ const mockBookReview: BookReview = { userId: 1, rating: 3.5, keywords: [ - { id: 3, name: '감동', type: 'BOOK' }, - { id: 7, name: '몰입', type: 'IMPRESSION' }, + { id: 43, name: '관계', type: 'BOOK' }, + { id: 18, name: '흥미로운', type: 'IMPRESSION' }, ], createdAt: '2026-01-11T10:00:00', } @@ -462,20 +462,20 @@ const mockBookReviewHistoryResponse: GetBookReviewHistoryResponse = { createdAt: '2025-12-15T10:30:00', rating: 3.5, bookKeywords: [ - { id: 1, name: '관계', type: 'BOOK' }, - { id: 2, name: '성장', type: 'BOOK' }, + { id: 43, name: '관계', type: 'BOOK' }, + { id: 47, name: '성장', type: 'BOOK' }, ], impressionKeywords: [ - { id: 10, name: '즐거운', type: 'IMPRESSION' }, - { id: 11, name: '여운이 남는', type: 'IMPRESSION' }, + { id: 9, name: '즐거운', type: 'IMPRESSION' }, + { id: 20, name: '여운이 남는', type: 'IMPRESSION' }, ], }, { bookReviewHistoryId: 1, createdAt: '2025-12-08T14:20:00', rating: 4, - bookKeywords: [{ id: 3, name: '감동', type: 'BOOK' }], - impressionKeywords: [{ id: 12, name: '몰입되는', type: 'IMPRESSION' }], + bookKeywords: [{ id: 52, name: '삶', type: 'BOOK' }], + impressionKeywords: [{ id: 12, name: '뭉클한', type: 'IMPRESSION' }], }, ], pageSize: 5, diff --git a/src/features/book/components/BookReviewForm.tsx b/src/features/book/components/BookReviewForm.tsx new file mode 100644 index 0000000..9dc80be --- /dev/null +++ b/src/features/book/components/BookReviewForm.tsx @@ -0,0 +1,299 @@ +import { X } from 'lucide-react' +import { useMemo, useState } from 'react' + +import { useKeywords } from '@/features/keywords' +import { StarRate } from '@/shared/components/StarRate' +import { TextButton } from '@/shared/ui' +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: 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: nextBookCount > 0 && nextImpressionCount > 0, + }) + } + + if (isKeywordsError) { + return ( +
+

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

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

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

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

별점

+
+ +

{rating.toFixed(1)}

+ {rating >= 0.5 && ( + handleRatingChange(0)}>별점 초기화 + )} +
+
+ + {/* 책 키워드 */} +
+

책 키워드

+ + {/* 카테고리 탭 */} + + 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 3d3f662..c807159 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' /** * 책 평가하기 모달 @@ -38,118 +34,30 @@ export interface BookReviewModalProps { onOpenChange: (open: boolean) => void } -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 INITIAL_FORM_VALUES: BookReviewFormValues = { + rating: 0, + keywordIds: [], + isValid: false, +} - const { - data: keywordsData, - isLoading: isLoadingKeywords, - isError: isKeywordsError, - } = useKeywords() +export function BookReviewModal({ bookId, open, onOpenChange }: BookReviewModalProps) { + const [formValues, setFormValues] = useState(INITIAL_FORM_VALUES) 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 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + setFormValues(INITIAL_FORM_VALUES) } - - 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] - ) + onOpenChange(newOpen) } const handleSubmit = () => { - if (rating === 0) { - openError('별점 필요', '별점을 선택해주세요.') - return - } - submitReview( - { rating, keywordIds: selectedKeywordIds }, + { rating: formValues.rating, keywordIds: formValues.keywordIds }, { onSuccess: () => { - onOpenChange(false) - // 상태 초기화 - setRating(0) - setSelectedKeywordIds([]) - setSelectedBookCategoryId(null) - setSelectedImpressionCategoryId(null) + handleOpenChange(false) }, onError: (error) => { openError('평가 저장 실패', error.message) @@ -158,178 +66,21 @@ export function BookReviewModal({ bookId, open, onOpenChange }: BookReviewModalP ) } - 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/keywords/index.ts b/src/features/keywords/index.ts index 3d8b699..f1985d8 100644 --- a/src/features/keywords/index.ts +++ b/src/features/keywords/index.ts @@ -1,3 +1,5 @@ export * from './hooks' export * from './keywords.api' +export * from './keywords.endpoints' +export * from './keywords.mock' export * from './keywords.types' diff --git a/src/features/keywords/keywords.api.ts b/src/features/keywords/keywords.api.ts index f9405ae..eb5e48b 100644 --- a/src/features/keywords/keywords.api.ts +++ b/src/features/keywords/keywords.api.ts @@ -1,732 +1,16 @@ /** * @file keywords.api.ts - * @description 키워드 API 함수 + * @description 키워드 API 요청 함수 */ import { api } from '@/api' - -import type { GetKeywordsResponse } from './keywords.types' - -// ============================================================ -// Mock Data -// ============================================================ +import { KEYWORDS_ENDPOINTS } from '@/features/keywords/keywords.endpoints' +import { getMockKeywords } from '@/features/keywords/keywords.mock' +import type { GetKeywordsResponse } from '@/features/keywords/keywords.types' /** 목데이터 사용 여부 플래그 */ const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' -const mockKeywordsResponse: GetKeywordsResponse = { - keywords: [ - // ============================================================ - // 책 키워드 카테고리 (BOOK, level 1) - // ============================================================ - { - id: 1, - name: '인간관계', - type: 'BOOK', - parentId: null, - parentName: null, - level: 1, - sortOrder: 1, - isSelectable: false, - }, - { - id: 2, - name: '개인', - type: 'BOOK', - parentId: null, - parentName: null, - level: 1, - sortOrder: 2, - isSelectable: false, - }, - { - id: 3, - name: '삶과 죽음', - type: 'BOOK', - parentId: null, - parentName: null, - level: 1, - sortOrder: 3, - isSelectable: false, - }, - { - id: 4, - name: '사회', - type: 'BOOK', - parentId: null, - parentName: null, - level: 1, - sortOrder: 4, - isSelectable: false, - }, - { - id: 5, - name: '기타', - type: 'BOOK', - parentId: null, - parentName: null, - level: 1, - sortOrder: 5, - isSelectable: false, - }, - - // ============================================================ - // 인간관계 키워드 (level 2, parentId: 1) - // ============================================================ - { - id: 11, - name: '사랑', - type: 'BOOK', - parentId: 1, - parentName: '인간관계', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 12, - name: '관계', - type: 'BOOK', - parentId: 1, - parentName: '인간관계', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 13, - name: '가족', - type: 'BOOK', - parentId: 1, - parentName: '인간관계', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 14, - name: '우정', - type: 'BOOK', - parentId: 1, - parentName: '인간관계', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 15, - name: '이별', - type: 'BOOK', - parentId: 1, - parentName: '인간관계', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - - // ============================================================ - // 개인 키워드 (level 2, parentId: 2) - // ============================================================ - { - id: 21, - name: '성장', - type: 'BOOK', - parentId: 2, - parentName: '개인', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 22, - name: '자아', - type: 'BOOK', - parentId: 2, - parentName: '개인', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 23, - name: '고독', - type: 'BOOK', - parentId: 2, - parentName: '개인', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 24, - name: '선택', - type: 'BOOK', - parentId: 2, - parentName: '개인', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 25, - name: '자유', - type: 'BOOK', - parentId: 2, - parentName: '개인', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - - // ============================================================ - // 삶과 죽음 키워드 (level 2, parentId: 3) - // ============================================================ - { - id: 31, - name: '삶', - type: 'BOOK', - parentId: 3, - parentName: '삶과 죽음', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 32, - name: '죽음', - type: 'BOOK', - parentId: 3, - parentName: '삶과 죽음', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 33, - name: '상실', - type: 'BOOK', - parentId: 3, - parentName: '삶과 죽음', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 34, - name: '치유', - type: 'BOOK', - parentId: 3, - parentName: '삶과 죽음', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 35, - name: '기억', - type: 'BOOK', - parentId: 3, - parentName: '삶과 죽음', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - - // ============================================================ - // 사회 키워드 (level 2, parentId: 4) - // ============================================================ - { - id: 41, - name: '사회', - type: 'BOOK', - parentId: 4, - parentName: '사회', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 42, - name: '현실', - type: 'BOOK', - parentId: 4, - parentName: '사회', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 43, - name: '역사', - type: 'BOOK', - parentId: 4, - parentName: '사회', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 44, - name: '노동', - type: 'BOOK', - parentId: 4, - parentName: '사회', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 45, - name: '여성', - type: 'BOOK', - parentId: 4, - parentName: '사회', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - { - id: 46, - name: '윤리', - type: 'BOOK', - parentId: 4, - parentName: '사회', - level: 2, - sortOrder: 6, - isSelectable: true, - }, - - // ============================================================ - // 기타 키워드 (level 2, parentId: 5) - // ============================================================ - { - id: 51, - name: '청춘', - type: 'BOOK', - parentId: 5, - parentName: '기타', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 52, - name: '모험', - type: 'BOOK', - parentId: 5, - parentName: '기타', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 53, - name: '판타지', - type: 'BOOK', - parentId: 5, - parentName: '기타', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 54, - name: '추리', - type: 'BOOK', - parentId: 5, - parentName: '기타', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - - // ============================================================ - // 감상 키워드 카테고리 (IMPRESSION, level 1) - // ============================================================ - { - id: 101, - name: '긍정', - type: 'IMPRESSION', - parentId: null, - parentName: null, - level: 1, - sortOrder: 1, - isSelectable: false, - }, - { - id: 102, - name: '감상', - type: 'IMPRESSION', - parentId: null, - parentName: null, - level: 1, - sortOrder: 2, - isSelectable: false, - }, - { - id: 103, - name: '부정', - type: 'IMPRESSION', - parentId: null, - parentName: null, - level: 1, - sortOrder: 3, - isSelectable: false, - }, - - // ============================================================ - // 긍정 키워드 (level 2, parentId: 101) - // ============================================================ - { - id: 111, - name: '즐거운', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 112, - name: '감동적인', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 113, - name: '위로받은', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 114, - name: '뭉클한', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 115, - name: '후련한', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - { - id: 116, - name: '벅찬', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 6, - isSelectable: true, - }, - { - id: 117, - name: '안도한', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 7, - isSelectable: true, - }, - { - id: 118, - name: '희망이 생긴', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 8, - isSelectable: true, - }, - { - id: 119, - name: '설레는', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 9, - isSelectable: true, - }, - { - id: 120, - name: '흥미로운', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 10, - isSelectable: true, - }, - { - id: 121, - name: '빠져든', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 11, - isSelectable: true, - }, - - // ============================================================ - // 감상 키워드 (level 2, parentId: 102) - // ============================================================ - { - id: 122, - name: '여운이 남는', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 123, - name: '먹먹한', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 124, - name: '울컥한', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 125, - name: '찡한', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 126, - name: '그리운', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - { - id: 127, - name: '익숙한', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 6, - isSelectable: true, - }, - { - id: 128, - name: '이해가 되는', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 7, - isSelectable: true, - }, - { - id: 129, - name: '의문이 남는', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 8, - isSelectable: true, - }, - - // ============================================================ - // 부정 키워드 (level 2, parentId: 103) - // ============================================================ - { - id: 131, - name: '지루한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 132, - name: '씁쓸한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 133, - name: '허무한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 134, - name: '찝찝한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 135, - name: '공허한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - { - id: 136, - name: '서글픈', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 6, - isSelectable: true, - }, - { - id: 137, - name: '분노가 이는', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 7, - isSelectable: true, - }, - { - id: 138, - name: '복잡한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 8, - isSelectable: true, - }, - { - id: 139, - name: '허탈한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 9, - isSelectable: true, - }, - { - id: 140, - name: '불안한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 10, - isSelectable: true, - }, - { - id: 141, - name: '괴로운', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 11, - isSelectable: true, - }, - { - id: 142, - name: '안타까운', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 12, - isSelectable: true, - }, - { - id: 143, - name: '답답한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 13, - isSelectable: true, - }, - { - id: 144, - name: '슬픈', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 14, - isSelectable: true, - }, - ], -} - -/** 목데이터 응답 지연 시뮬레이션 (ms) */ -const MOCK_DELAY = 500 - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -// ============================================================ -// API Functions -// ============================================================ - /** * 키워드 목록 조회 * @@ -741,9 +25,9 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) */ export async function getKeywords(): Promise { if (USE_MOCK) { - await delay(MOCK_DELAY) - return mockKeywordsResponse + await new Promise((resolve) => setTimeout(resolve, 500)) + return getMockKeywords() } - return api.get('/api/keywords') + return api.get(KEYWORDS_ENDPOINTS.LIST) } diff --git a/src/features/keywords/keywords.endpoints.ts b/src/features/keywords/keywords.endpoints.ts new file mode 100644 index 0000000..8235aa4 --- /dev/null +++ b/src/features/keywords/keywords.endpoints.ts @@ -0,0 +1,6 @@ +import { API_PATHS } from '@/api' + +export const KEYWORDS_ENDPOINTS = { + // 키워드 목록 조회 (GET /api/keywords) + LIST: API_PATHS.KEYWORDS, +} as const diff --git a/src/features/keywords/keywords.mock.ts b/src/features/keywords/keywords.mock.ts new file mode 100644 index 0000000..e6d0d5f --- /dev/null +++ b/src/features/keywords/keywords.mock.ts @@ -0,0 +1,723 @@ +/** + * @file keywords.mock.ts + * @description 키워드 API 목데이터 + */ + +import type { GetKeywordsResponse } from '@/features/keywords/keywords.types' + +/** + * 키워드 목록 조회 목데이터 + */ +const mockKeywordsResponse: GetKeywordsResponse = { + keywords: [ + // ============================================================ + // 책 키워드 카테고리 (BOOK, level 1) + // ============================================================ + { + id: 1, + name: '인간관계', + type: 'BOOK', + parentId: null, + parentName: null, + level: 1, + sortOrder: 1, + isSelectable: false, + }, + { + id: 2, + name: '개인', + type: 'BOOK', + parentId: null, + parentName: null, + level: 1, + sortOrder: 2, + isSelectable: false, + }, + { + id: 3, + name: '삶과 죽음', + type: 'BOOK', + parentId: null, + parentName: null, + level: 1, + sortOrder: 3, + isSelectable: false, + }, + { + id: 4, + name: '사회', + type: 'BOOK', + parentId: null, + parentName: null, + level: 1, + sortOrder: 4, + isSelectable: false, + }, + { + id: 5, + name: '기타', + type: 'BOOK', + parentId: null, + parentName: null, + level: 1, + sortOrder: 5, + isSelectable: false, + }, + + // ============================================================ + // 인간관계 키워드 (level 2, parentId: 1) + // ============================================================ + { + id: 42, + name: '사랑', + type: 'BOOK', + parentId: 1, + parentName: '인간관계', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 43, + name: '관계', + type: 'BOOK', + parentId: 1, + parentName: '인간관계', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 44, + name: '가족', + type: 'BOOK', + parentId: 1, + parentName: '인간관계', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 45, + name: '우정', + type: 'BOOK', + parentId: 1, + parentName: '인간관계', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 46, + name: '이별', + type: 'BOOK', + parentId: 1, + parentName: '인간관계', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + + // ============================================================ + // 개인 키워드 (level 2, parentId: 2) + // ============================================================ + { + id: 47, + name: '성장', + type: 'BOOK', + parentId: 2, + parentName: '개인', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 48, + name: '자아', + type: 'BOOK', + parentId: 2, + parentName: '개인', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 49, + name: '고독', + type: 'BOOK', + parentId: 2, + parentName: '개인', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 50, + name: '선택', + type: 'BOOK', + parentId: 2, + parentName: '개인', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 51, + name: '자유', + type: 'BOOK', + parentId: 2, + parentName: '개인', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + + // ============================================================ + // 삶과 죽음 키워드 (level 2, parentId: 3) + // ============================================================ + { + id: 52, + name: '삶', + type: 'BOOK', + parentId: 3, + parentName: '삶과 죽음', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 53, + name: '죽음', + type: 'BOOK', + parentId: 3, + parentName: '삶과 죽음', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 54, + name: '상실', + type: 'BOOK', + parentId: 3, + parentName: '삶과 죽음', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 55, + name: '치유', + type: 'BOOK', + parentId: 3, + parentName: '삶과 죽음', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 56, + name: '기억', + type: 'BOOK', + parentId: 3, + parentName: '삶과 죽음', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + + // ============================================================ + // 사회 키워드 (level 2, parentId: 4) + // ============================================================ + { + id: 57, + name: '사회', + type: 'BOOK', + parentId: 4, + parentName: '사회', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 58, + name: '현실', + type: 'BOOK', + parentId: 4, + parentName: '사회', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 59, + name: '역사', + type: 'BOOK', + parentId: 4, + parentName: '사회', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 60, + name: '노동', + type: 'BOOK', + parentId: 4, + parentName: '사회', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 61, + name: '여성', + type: 'BOOK', + parentId: 4, + parentName: '사회', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + { + id: 62, + name: '윤리', + type: 'BOOK', + parentId: 4, + parentName: '사회', + level: 2, + sortOrder: 6, + isSelectable: true, + }, + + // ============================================================ + // 기타 키워드 (level 2, parentId: 5) + // ============================================================ + { + id: 63, + name: '청춘', + type: 'BOOK', + parentId: 5, + parentName: '기타', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 64, + name: '모험', + type: 'BOOK', + parentId: 5, + parentName: '기타', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 65, + name: '판타지', + type: 'BOOK', + parentId: 5, + parentName: '기타', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 66, + name: '추리', + type: 'BOOK', + parentId: 5, + parentName: '기타', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + + // ============================================================ + // 감상 키워드 카테고리 (IMPRESSION, level 1) + // ============================================================ + { + id: 6, + name: '긍정', + type: 'IMPRESSION', + parentId: null, + parentName: null, + level: 1, + sortOrder: 1, + isSelectable: false, + }, + { + id: 7, + name: '감상', + type: 'IMPRESSION', + parentId: null, + parentName: null, + level: 1, + sortOrder: 2, + isSelectable: false, + }, + { + id: 8, + name: '부정', + type: 'IMPRESSION', + parentId: null, + parentName: null, + level: 1, + sortOrder: 3, + isSelectable: false, + }, + + // ============================================================ + // 긍정 키워드 (level 2, parentId: 6) + // ============================================================ + { + id: 9, + name: '즐거운', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 10, + name: '감동적인', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 11, + name: '위로받은', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 12, + name: '뭉클한', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 13, + name: '후련한', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + { + id: 14, + name: '벅찬', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 6, + isSelectable: true, + }, + { + id: 15, + name: '안도한', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 7, + isSelectable: true, + }, + { + id: 16, + name: '희망이 생긴', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 8, + isSelectable: true, + }, + { + id: 17, + name: '설레는', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 9, + isSelectable: true, + }, + { + id: 18, + name: '흥미로운', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 10, + isSelectable: true, + }, + { + id: 19, + name: '빠져든', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 11, + isSelectable: true, + }, + + // ============================================================ + // 감상 키워드 (level 2, parentId: 7) + // ============================================================ + { + id: 20, + name: '여운이 남는', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 21, + name: '먹먹한', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 22, + name: '울컥한', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 23, + name: '찡한', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 24, + name: '그리운', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + { + id: 25, + name: '익숙한', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 6, + isSelectable: true, + }, + { + id: 26, + name: '이해가 되는', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 7, + isSelectable: true, + }, + { + id: 27, + name: '의문이 남는', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 8, + isSelectable: true, + }, + + // ============================================================ + // 부정 키워드 (level 2, parentId: 8) + // ============================================================ + { + id: 28, + name: '지루한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 29, + name: '씁쓸한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 30, + name: '허무한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 31, + name: '찝찝한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 32, + name: '공허한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + { + id: 33, + name: '서글픈', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 6, + isSelectable: true, + }, + { + id: 34, + name: '분노가 이는', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 7, + isSelectable: true, + }, + { + id: 35, + name: '복잡한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 8, + isSelectable: true, + }, + { + id: 36, + name: '허탈한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 9, + isSelectable: true, + }, + { + id: 37, + name: '불안한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 10, + isSelectable: true, + }, + { + id: 38, + name: '괴로운', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 11, + isSelectable: true, + }, + { + id: 39, + name: '안타까운', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 12, + isSelectable: true, + }, + { + id: 40, + name: '답답한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 13, + isSelectable: true, + }, + { + id: 41, + name: '슬픈', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 14, + isSelectable: true, + }, + ], +} + +/** + * 키워드 목록 조회 목데이터 반환 함수 + * + * @description + * 실제 API 호출을 시뮬레이션하여 키워드 목데이터를 반환합니다. + */ +export const getMockKeywords = (): GetKeywordsResponse => { + return mockKeywordsResponse +} diff --git a/src/features/pre-opinion/components/BookReviewSection.tsx b/src/features/pre-opinion/components/BookReviewSection.tsx new file mode 100644 index 0000000..8be7ef3 --- /dev/null +++ b/src/features/pre-opinion/components/BookReviewSection.tsx @@ -0,0 +1,59 @@ +import { + BookReviewForm, + type BookReviewFormValues, +} from '@/features/book/components/BookReviewForm' +import type { PreOpinionReview } from '@/features/pre-opinion/preOpinion.types' +import { Container } from '@/shared/ui' + +/** + * 사전 의견 작성 페이지의 책 평가 섹션 + * + * @description 기존 평가가 있으면 해당 데이터를 채워서 보여주고, + * 없으면 빈 폼을 보여줘서 사용자가 평가를 남길 수 있게 합니다. + * 제출은 상위 페이지에서 일괄 처리합니다. + * + * @example + * ```tsx + * setReviewValues(values)} /> + * ``` + */ +interface BookReviewSectionProps { + review: PreOpinionReview | null + onChange?: (values: BookReviewFormValues) => void +} + +const BookReviewSection = ({ review, onChange }: BookReviewSectionProps) => { + const hasReview = review !== null + + return ( + + + 이 책은 어떠셨나요? + + + k.id) + .sort() + .join(',')}` + : 'empty' + } + initialRating={review?.rating ?? 0} + initialKeywordIds={review?.keywords.map((k) => k.id) ?? []} + onChange={onChange} + /> + + + ) +} + +export default BookReviewSection diff --git a/src/features/pre-opinion/components/PreOpinionWriteHeader.tsx b/src/features/pre-opinion/components/PreOpinionWriteHeader.tsx new file mode 100644 index 0000000..9975621 --- /dev/null +++ b/src/features/pre-opinion/components/PreOpinionWriteHeader.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from 'react' + +import type { PreOpinionBook } from '@/features/pre-opinion/preOpinion.types' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui' + +import { formatUpdatedAt } from '../lib/date' + +interface PreOpinionWriteHeaderProps { + book: PreOpinionBook + updatedAt: string | null + onSave: () => void + onSubmit: () => void + isSaving?: boolean + isSubmitting?: boolean + isReviewValid?: boolean +} + +/** + * 사전 의견 작성 페이지 헤더 + * + * @description + * 책 제목, 저자, 마지막 저장 시각을 표시하고 + * 스크롤 시 sticky로 고정되며 하단 그림자가 생깁니다. + * + * @example + * ```tsx + * + * ``` + */ +const PreOpinionWriteHeader = ({ + book, + updatedAt, + onSave, + onSubmit, + isSaving, + isSubmitting, + isReviewValid = false, +}: PreOpinionWriteHeaderProps) => { + 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 ( + <> +
+
+
+
+
+

사전 의견 작성하기

+

+ {book.title} · {book.author} +

+
+
+ {updatedAt && ( +

{formatUpdatedAt(updatedAt)}

+ )} + + +
+
+
+
+ + ) +} + +export default PreOpinionWriteHeader diff --git a/src/features/pre-opinion/components/TopicItem.tsx b/src/features/pre-opinion/components/TopicItem.tsx new file mode 100644 index 0000000..f3bfee5 --- /dev/null +++ b/src/features/pre-opinion/components/TopicItem.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react' + +import type { PreOpinionTopic } from '@/features/pre-opinion/preOpinion.types' +import { Badge, Container, Textarea } from '@/shared/ui' + +interface TopicItemProps { + topic: PreOpinionTopic + onChange?: (topicId: number, content: string) => void +} + +function TopicItem({ topic, onChange }: TopicItemProps) { + const [value, setValue] = useState(topic.content ?? '') + const [prevContent, setPrevContent] = useState(topic.content) + + if (topic.content !== prevContent) { + setPrevContent(topic.content) + setValue(topic.content ?? '') + } + + return ( + + {topic.topicTypeLabel}} + > + {`주제 ${topic.confirmOrder}. ${topic.topicTitle}`} + + +
+

{topic.topicDescription}

+