Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/features/topics/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './topicQueryKeys'
export * from './useConfirmedTopics'
export * from './useCreateTopic'
export * from './useDeleteTopic'
export * from './useLikeTopic'
export * from './useProposedTopics'
52 changes: 52 additions & 0 deletions src/features/topics/hooks/useCreateTopic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* @file useCreateTopic.ts
* @description 주제 제안 mutation 훅
*/

import { useMutation, useQueryClient } from '@tanstack/react-query'

import { ApiError } from '@/api/errors'

import { createTopic } from '../topics.api'
import type { CreateTopicParams, CreateTopicResponse } from '../topics.types'
import { topicQueryKeys } from './topicQueryKeys'

/**
* 주제 제안 mutation 훅
*
* @description
* 주제를 제안하고 관련 쿼리 캐시를 무효화합니다.
* - 제안된 주제 리스트 캐시 무효화
*
* @example
* ```tsx
* const createMutation = useCreateTopic()
* createMutation.mutate(
* { gatheringId: 1, meetingId: 2, body: { title: '주제 제목', topicType: 'FREE' } },
* {
* onSuccess: () => {
* console.log('주제가 제안되었습니다.')
* },
* onError: (error) => {
* console.error('제안 실패:', error.userMessage)
* },
* }
* )
* ```
*/
export const useCreateTopic = () => {
const queryClient = useQueryClient()

return useMutation<CreateTopicResponse, ApiError, CreateTopicParams>({
mutationFn: (params: CreateTopicParams) => createTopic(params),
onSuccess: (_, variables) => {
// 제안된 주제 목록 캐시 무효화
queryClient.invalidateQueries({
queryKey: topicQueryKeys.proposedList({
gatheringId: variables.gatheringId,
meetingId: variables.meetingId,
}),
})
},
})
}
3 changes: 3 additions & 0 deletions src/features/topics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ export * from './topics.mock'

// Types
export * from './topics.types'

// Constants
export * from './topics.constants'
35 changes: 35 additions & 0 deletions src/features/topics/topics.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { PAGE_SIZES } from '@/shared/constants'
import { TOPICS_ENDPOINTS } from './topics.endpoints'
import { getMockConfirmedTopics, getMockProposedTopics } from './topics.mock'
import type {
CreateTopicParams,
CreateTopicResponse,
DeleteTopicParams,
GetConfirmedTopicsParams,
GetConfirmedTopicsResponse,
Expand Down Expand Up @@ -143,6 +145,39 @@ export const deleteTopic = async (params: DeleteTopicParams): Promise<void> => {
return api.delete<void>(TOPICS_ENDPOINTS.DELETE(gatheringId, meetingId, topicId))
}

/**
* 주제 제안
*
* @description
* 약속에 새로운 주제를 제안합니다.
*
* @param params - 제안 파라미터
* @param params.gatheringId - 모임 식별자
* @param params.meetingId - 약속 식별자
* @param params.body - 요청 바디 (제목, 설명, 주제 타입)
*
* @returns 생성된 주제 정보
*/
export const createTopic = async (params: CreateTopicParams): Promise<CreateTopicResponse> => {
const { gatheringId, meetingId, body } = params

// 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용
// TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거
if (USE_MOCK_DATA) {
// 실제 API 호출을 시뮬레이션하기 위한 지연
await new Promise((resolve) => setTimeout(resolve, 500))
return {
topicId: Math.floor(Math.random() * 1000),
title: body.title,
description: body.description || null,
topicType: body.topicType,
}
}

// 실제 API 호출 (로그인 완료 후 사용)
return api.post<CreateTopicResponse>(TOPICS_ENDPOINTS.CREATE(gatheringId, meetingId), body)
}

/**
* 주제 좋아요 토글
*
Expand Down
55 changes: 55 additions & 0 deletions src/features/topics/topics.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { TopicType } from '@/features/topics/topics.types'

export const TOPIC_TYPE_META: Record<
TopicType,
{
label: string
hint: string
}
> = {
FREE: {
label: '자유형',
hint: '자유롭게 이야기 나누는 주제입니다',
},
DISCUSSION: {
label: '토론형',
hint: '찬반 토론이나 다양한 관점을 나누는 주제입니다',
},
EMOTION: {
label: '감정 공유형',
hint: '책을 읽으며 느낀 감정을 공유하는 주제입니다',
},
EXPERIENCE: {
label: '경험 연결형',
hint: '책의 내용을 개인 경험과 연결하는 주제입니다',
},
CHARACTER_ANALYSIS: {
label: '인물 분석형',
hint: '등장인물의 성격, 동기, 변화를 분석하는 주제입니다',
},
COMPARISON: {
label: '비교 분석형',
hint: '다른 작품이나 현실과 비교하는 주제입니다',
},
STRUCTURE: {
label: '구조 분석형',
hint: '책의 구성, 서술 방식, 문제를 분석하는 주제입니다',
},
IN_DEPTH: {
label: '심층 분석형',
hint: '주제, 상징, 메시지를 깊이 있게 분석하는 주제입니다',
},
CREATIVE: {
label: '창작형',
hint: '후속 이야기나 다른 결말을 창작하는 주제입니다',
},
CUSTOM: {
label: '질문형',
hint: '궁금한 점을 질문하고 함께 답을 찾는 주제입니다',
},
}

export const TOPIC_TYPE_OPTIONS = (Object.keys(TOPIC_TYPE_META) as TopicType[]).map((key) => ({
value: key,
...TOPIC_TYPE_META[key],
}))
4 changes: 4 additions & 0 deletions src/features/topics/topics.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ export const TOPICS_ENDPOINTS = {
// 주제 좋아요 토글 (POST /api/gatherings/{gatheringId}/meetings/{meetingId}/topics/{topicId}/likes)
LIKE_TOGGLE: (gatheringId: number, meetingId: number, topicId: number) =>
`${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics/${topicId}/likes`,

// 주제 제안 (POST /api/gatherings/{gatheringId}/meetings/{meetingId}/topics)
CREATE: (gatheringId: number, meetingId: number) =>
`${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics`,
} as const
38 changes: 38 additions & 0 deletions src/features/topics/topics.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,41 @@ export type LikeTopicResponse = {
/** 새로운 좋아요 수 */
newCount: number
}

/**
* 주제 제안 요청 파라미터
*/
export type CreateTopicParams = {
/** 모임 식별자 */
gatheringId: number
/** 약속 식별자 */
meetingId: number
/** 요청 바디 */
body: CreateTopicRequest
}

/**
* 주제 제안 요청 바디
*/
export type CreateTopicRequest = {
/** 주제 제목 */
title: string
/** 주제 설명 (Todo: 없을때 어떻게 보낼지 체크해야 함)*/
description: string | null
/** 주제 타입 */
topicType: TopicType
}

/**
* 주제 제안 응답
*/
export type CreateTopicResponse = {
/** 주제 ID */
topicId: number
/** 주제 제목 */
title: string
/** 주제 설명 (Todo: 없을때 null인지 빈값인지 체크해야 함)*/
description: string | null
/** 주제 타입 */
topicType: TopicType
}
159 changes: 159 additions & 0 deletions src/pages/Topics/TopicCreatePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { ChevronLeft, Info } from 'lucide-react'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'

import {
TOPIC_TYPE_META,
TOPIC_TYPE_OPTIONS,
type TopicType,
useCreateTopic,
} from '@/features/topics'
import {
Button,
Container,
Input,
Textarea,
TopicTypeSelectGroup,
TopicTypeSelectItem,
} from '@/shared/ui'
import { useGlobalModalStore } from '@/store'

export default function TopicCreatePage() {
const navigate = useNavigate()
const { gatheringId, meetingId } = useParams<{ gatheringId: string; meetingId: string }>()
const { openError } = useGlobalModalStore()
const createMutation = useCreateTopic()

const [topicType, setTopicType] = useState<TopicType>('FREE')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [errors, setErrors] = useState<{ title?: string }>({})

const topicTypeHint = TOPIC_TYPE_META[topicType].hint

const validateForm = () => {
const newErrors: { title?: string } = {}

if (!title.trim()) {
newErrors.title = '주제 제목을 입력해주세요.'
}

setErrors(newErrors)
return Object.keys(newErrors).length === 0
}

const handleSubmit = () => {
if (!validateForm()) return

const parsedGatheringId = Number(gatheringId)
const parsedMeetingId = Number(meetingId)
if (
Number.isNaN(parsedGatheringId) ||
parsedGatheringId <= 0 ||
Number.isNaN(parsedMeetingId) ||
parsedMeetingId <= 0
)
return

createMutation.mutate(
{
gatheringId: parsedGatheringId,
meetingId: parsedMeetingId,
body: {
title: title.trim(),
description: description.trim() || null,
topicType,
},
},
{
onSuccess: () => {
// TODO : 토스트로 교체
alert('주제 제안이 완료되었습니다.')
navigate(-1)
},
onError: (error) => {
openError('주제 제안 실패', error.userMessage)
},
}
)
}

return (
<>
{/* 공통컴포넌트로 교체 예정 */}
<div className="sticky top-0 bg-white -mt-xlarge">
<p className="flex typo-body3 text-grey-600 gap-xtiny py-medium">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

뒤로 가기 공통 컴포넌트는 제가 레이아웃 정리 작업할 때 같이 진행하면 되는건가요?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 제가 작업해둔 거 있어요 SubPageHeader

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

땡큐베리 감사합니다

<ChevronLeft size={16} /> 뒤로가기
</p>
<div className="flex justify-between py-large">
<p className="text-black typo-heading3">주제 제안하기</p>
<Button
className="w-fit"
size="small"
onClick={handleSubmit}
disabled={createMutation.isPending}
>
{createMutation.isPending ? '...' : '제안하기'}
</Button>
</div>
</div>
{/* 공통컴포넌트로 교체 예정 */}
<div className="flex flex-col gap-base pb-xlarge">
<Container>
<Container.Title className="typo-subtitle3" required>
주제 타입선택
</Container.Title>
<Container.Content>
<div className="flex flex-col gap-base">
{/* TopicTypeSelectGroup이 제네릭 타입 지원해주면 캐스팅 제거 가능 */}
<TopicTypeSelectGroup
type="single"
value={topicType}
onChange={(value) => setTopicType(value as TopicType)}
className="grid grid-cols-3 gap-xsmall lg:grid-cols-4 xl:grid-cols-5"
>
{TOPIC_TYPE_OPTIONS.map(({ value, label }) => (
<TopicTypeSelectItem key={value} value={value} className="typo-body3">
{label}
</TopicTypeSelectItem>
))}
</TopicTypeSelectGroup>
<p className="typo-body3 text-purple-200 flex gap-tiny items-center">
<Info size="16" /> {topicTypeHint}
</p>
</div>
</Container.Content>
</Container>

<Container>
<Container.Title className="typo-subtitle3" required>
주제 제목
</Container.Title>
<Container.Content>
<Input
maxLength={24}
placeholder="예: 주인공의 선택은 옳았을까요?"
value={title}
onChange={(e) => setTitle(e.target.value)}
error={!!errors.title}
errorMessage={errors.title}
/>
</Container.Content>
</Container>

<Container>
<Container.Title className="typo-subtitle3">주제 설명</Container.Title>
<Container.Content>
<Textarea
maxLength={150}
placeholder="어떤 내용을 이야기 나누고 싶은지 자세히 설명해주세요"
height={150}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</Container.Content>
</Container>
</div>
</>
)
}
1 change: 1 addition & 0 deletions src/pages/Topics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as TopicCreatePage } from './TopicCreatePage'
1 change: 1 addition & 0 deletions src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './Home'
export * from './Meetings'
export * from './PreOpinions'
export * from './Records'
export * from './Topics'
Loading
Loading