Skip to content

Commit dd0e604

Browse files
authored
[feat] 약속 상세 화면 및 기능 구현 (#51)
* feat:약속 상세 UI 구현 (#43) * feat: 약속 상세 조회 기능 구현 (#43) - 약속 상세 페이지 API 연동 및 UI 컴포넌트 구현. - useMeetingDetail 훅으로 데이터 조회 및 상태 관리. - 약속 정보, 책 정보, 참가자, 버튼 상태를 표시하는 컴포넌트 분리. - 라우트 파라미터를 gatheringId/meetingId로 명확히 구분. * feat: 약속 참가/취소 기능 및 UI 개선 (#43) 약속 상세 페이지에 참가신청/취소 기능 및 UI 개선 작업: - 약속 참가신청/취소 API 연동 (useJoinMeeting, useCancelJoinMeeting 훅 추가) - 액션 버튼에 참가/취소 로직 구현 (confirm 모달 및 에러 처리 포함) - 시간 기반 약속 상태 표시 (약속 전/중/후 Badge 자동 계산) - 지도 모달 컴포넌트 추가 (MapModal, 추후 지도 API 연동 예정) - 날짜/시간 표시 개선 (시작~종료 시간 분리 표시) - API 엔드포인트 상수화 (meetings.endpoints.ts) - 타입명 오타 수정 (MeddtingDetailActionStateType → MeetingDetailActionStateType) * design: 책 표지css수정(#43) * style: 줄바꿈 변경(#43) * refactor: 약속 스케줄 nullable 처리 및 쿼리 키 충돌 방지 (#43) - 약속 생성 시 스케줄이 없는 경우를 처리하기 위해 schedule 타입을 nullable로 변경. - MeetingDetailInfo와 MeetingDetailHeader에서 스케줄 데이터 유무에 따라 조건부 렌더링 적용. - useMeetingDetail 훅에서 유효하지 않은 meetingId로 인한 쿼리 키 충돌 방지 로직 추가. - 약속 설정 페이지 라우팅 경로 수정. * refactor: 약속 상세 컴포넌트 구조 개선 및 타입 정리 (#43) - 컴포넌트 export 방식을 named에서 default로 통일 - 약속장 role을 HOST에서 LEADER로 변경 - 날짜/시간 유틸리티 함수 파일 구조 재정리 (dateTimeFormatters, dateTimeUtils 분리) - 약속 진행 상태(progressStatus) 기반 배지 표시 로직 추가 - 불필요한 null 체크 제거 및 타입 안정성 향상 - 라우팅 경로 구조 개선 (하드코딩 제거) * fix: 목데이터 수정(#43) * feat: 약속 상태에 따른 뱃지 색 구현 (#43)
1 parent 7d00002 commit dd0e604

28 files changed

Lines changed: 927 additions & 79 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { MeetingLocation } from '@/features/meetings/meetings.types'
2+
import { Modal, ModalBody, ModalContent, ModalHeader, ModalTitle } from '@/shared/ui'
3+
4+
interface MapModalProps {
5+
open: boolean
6+
onOpenChange: (open: boolean) => void
7+
location: MeetingLocation
8+
}
9+
10+
export default function MapModal({ open, onOpenChange, location }: MapModalProps) {
11+
return (
12+
<Modal open={open} onOpenChange={onOpenChange}>
13+
<ModalContent variant="normal">
14+
<ModalHeader>
15+
<ModalTitle>{location.name}</ModalTitle>
16+
</ModalHeader>
17+
<ModalBody>
18+
<div className="flex items-center justify-center h-full text-grey-600 typo-body2">
19+
지도 API 연동 예정
20+
</div>
21+
</ModalBody>
22+
</ModalContent>
23+
</Modal>
24+
)
25+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useCancelJoinMeeting, useJoinMeeting } from '@/features/meetings/hooks'
2+
import type { MeetingDetailActionStateType } from '@/features/meetings/meetings.types'
3+
import { Button } from '@/shared/ui'
4+
import { useGlobalModalStore } from '@/store'
5+
6+
interface MeetingDetailButtonProps {
7+
buttonLabel: string
8+
isEnabled: boolean
9+
type: MeetingDetailActionStateType
10+
meetingId: number
11+
}
12+
13+
export default function MeetingDetailButton({
14+
buttonLabel,
15+
isEnabled,
16+
type,
17+
meetingId,
18+
}: MeetingDetailButtonProps) {
19+
const joinMutation = useJoinMeeting()
20+
const cancelJoinMutation = useCancelJoinMeeting()
21+
const { openError, openConfirm } = useGlobalModalStore()
22+
23+
const isPending = joinMutation.isPending || cancelJoinMutation.isPending
24+
25+
const handleClick = async () => {
26+
if (!isEnabled || isPending) return
27+
28+
// 약속 수정 - 페이지 이동 예정 (TODO)
29+
if (type === 'CAN_EDIT') {
30+
// 페이지 이동 로직 추가 예정
31+
return
32+
}
33+
34+
// 약속 참가신청
35+
if (type === 'CAN_JOIN') {
36+
const confirmed = await openConfirm('참가 신청', '약속 참가 신청을 하시겠습니까?')
37+
if (!confirmed) return
38+
39+
joinMutation.mutate(meetingId, {
40+
onSuccess: () => {
41+
alert('참가 신청이 완료되었습니다.')
42+
},
43+
onError: (error) => {
44+
openError('에러', error.userMessage)
45+
},
46+
})
47+
return
48+
}
49+
50+
// 약속 참가취소
51+
if (type === 'CAN_CANCEL') {
52+
const confirmed = await openConfirm('참가 신청 취소', '약속 참가 신청을 취소하시겠습니까?')
53+
if (!confirmed) return
54+
55+
cancelJoinMutation.mutate(meetingId, {
56+
onSuccess: () => {
57+
alert('참가 취소가 완료되었습니다.')
58+
},
59+
onError: (error) => {
60+
openError('에러', error.userMessage)
61+
},
62+
})
63+
return
64+
}
65+
}
66+
67+
return (
68+
<div>
69+
<Button
70+
size="medium"
71+
className="w-full"
72+
disabled={!isEnabled || isPending}
73+
onClick={handleClick}
74+
>
75+
{buttonLabel}
76+
</Button>
77+
{isEnabled && (
78+
<p className="text-grey-700 typo-body6 pt-tiny">
79+
{type === 'EDIT_TIME_EXPIRED' && '약속 24시간 전까지만 약속 정보를 수정할 수 있어요'}
80+
{(type === 'CANCEL_TIME_EXPIRED' || type === 'JOIN_TIME_EXPIRED') &&
81+
'* 약속 24시간 전까지만 참가 신청 및 취소가 가능해요'}
82+
</p>
83+
)}
84+
</div>
85+
)
86+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { MeetingProgressStatus } from '@/features/meetings/meetings.types'
2+
import { Badge } from '@/shared/ui'
3+
4+
interface MeetingDetailHeaderProps {
5+
children: string
6+
progressStatus: MeetingProgressStatus
7+
}
8+
type ProgressBadge = {
9+
text: '약속 전' | '약속 중' | '약속 후'
10+
color: 'yellow' | 'blue' | 'red'
11+
}
12+
export function MeetingDetailHeader({ children, progressStatus }: MeetingDetailHeaderProps) {
13+
const progressStatusLabelMap: Record<MeetingProgressStatus, ProgressBadge> = {
14+
PRE: { text: '약속 전', color: 'yellow' },
15+
ONGOING: { text: '약속 중', color: 'red' },
16+
POST: { text: '약속 후', color: 'blue' },
17+
}
18+
const { text, color } = progressStatusLabelMap[progressStatus]
19+
return (
20+
<div className="flex items-start border-b gap-small border-b-grey-300 pb-[10px]">
21+
<h3 className="text-black typo-heading3">{children}</h3>
22+
<Badge size="small" color={color}>
23+
{text}
24+
</Badge>
25+
</div>
26+
)
27+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { MapPin } from 'lucide-react'
2+
import { useState } from 'react'
3+
4+
import MapModal from '@/features/meetings/components/MapModal'
5+
import {
6+
Avatar,
7+
AvatarFallback,
8+
AvatarGroup,
9+
AvatarGroupCount,
10+
AvatarImage,
11+
TextButton,
12+
} from '@/shared/ui'
13+
14+
import type { GetMeetingDetailResponse } from '../meetings.types'
15+
16+
const MAX_DISPLAYED_AVATARS = 4
17+
const DT_VARIANTS = 'w-[68px] text-grey-600 typo-caption1'
18+
19+
interface MeetingDetailInfoProps {
20+
meeting: GetMeetingDetailResponse
21+
}
22+
23+
export function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) {
24+
const [isMapModalOpen, setIsMapModalOpen] = useState(false)
25+
26+
const leader = meeting.participants.members.find((member) => member.role === 'LEADER')
27+
const members = meeting.participants.members.filter((member) => member.role === 'MEMBER')
28+
const displayedMembers = members.slice(0, MAX_DISPLAYED_AVATARS)
29+
const remainingMembers = members.slice(MAX_DISPLAYED_AVATARS)
30+
const hasRegularMembers = members.length > 0
31+
const hasRemainingMembers = remainingMembers.length > 0
32+
33+
const [startDate, endDate] = meeting.schedule.displayDate.split(' ~ ')
34+
35+
return (
36+
<div className="w-[300px] flex-none flex flex-col gap-base">
37+
<div className="flex flex-col gap-medium">
38+
{/* 도서 */}
39+
<dl className="flex gap-base">
40+
<dt className={DT_VARIANTS}>도서</dt>
41+
<dd className="flex flex-col gap-xtiny">
42+
<p className="text-black typo-body3">{meeting.book.bookName}</p>
43+
<div className="w-[120px] h-[170px] overflow-hidden rounded">
44+
<img
45+
src={meeting.book.thumbnail}
46+
alt={meeting.book.bookName}
47+
className="object-cover w-full h-full"
48+
/>
49+
</div>
50+
</dd>
51+
</dl>
52+
53+
{/* 참가인원 */}
54+
<dl className="flex gap-base">
55+
<dt className={DT_VARIANTS}>참가인원</dt>
56+
<dd className="flex flex-col text-black typo-body3 gap-small">
57+
<p>
58+
{meeting.participants.currentCount}{' '}
59+
<span className="typo-caption2 text-grey-600">/{meeting.participants.maxCount}</span>
60+
</p>
61+
62+
{/* 약속장 */}
63+
{leader && (
64+
<div className="flex items-center gap-small">
65+
<p>약속장</p>
66+
<Avatar variant="host">
67+
<AvatarImage src={leader.profileImageUrl} alt={leader.nickname} />
68+
<AvatarFallback>{leader.nickname[0]}</AvatarFallback>
69+
</Avatar>
70+
</div>
71+
)}
72+
73+
{/* 멤버 */}
74+
{hasRegularMembers && (
75+
<div className="flex items-center gap-small">
76+
<p>멤버</p>
77+
<AvatarGroup>
78+
{displayedMembers.map((member) => (
79+
<Avatar key={member.userId}>
80+
<AvatarImage src={member.profileImageUrl} alt={member.nickname} />
81+
<AvatarFallback>{member.nickname[0]}</AvatarFallback>
82+
</Avatar>
83+
))}
84+
{hasRemainingMembers && (
85+
<AvatarGroupCount
86+
items={remainingMembers.map((member) => ({
87+
id: String(member.userId),
88+
name: member.nickname,
89+
src: member.profileImageUrl,
90+
fallbackText: member.nickname[0],
91+
}))}
92+
preview={{
93+
name: remainingMembers[0].nickname,
94+
src: remainingMembers[0].profileImageUrl,
95+
fallbackText: remainingMembers[0].nickname[0],
96+
}}
97+
>
98+
+{remainingMembers.length}
99+
</AvatarGroupCount>
100+
)}
101+
</AvatarGroup>
102+
</div>
103+
)}
104+
</dd>
105+
</dl>
106+
107+
{/* 날짜 및 시간 */}
108+
<dl className="flex gap-base">
109+
<dt className={DT_VARIANTS}>날짜 및 시간</dt>
110+
<dd className="text-black typo-body3">
111+
<p>{startDate}</p>
112+
<p>~ {endDate}</p>
113+
</dd>
114+
</dl>
115+
116+
{/* 장소 */}
117+
<dl className="flex gap-base">
118+
<dt className={DT_VARIANTS}>장소</dt>
119+
<dd>
120+
{meeting.location && (
121+
<TextButton
122+
size="medium"
123+
icon={MapPin}
124+
className="text-black typo-body3 [&_svg]:text-grey-600"
125+
onClick={() => setIsMapModalOpen(true)}
126+
>
127+
{meeting.location.name}
128+
</TextButton>
129+
)}
130+
</dd>
131+
</dl>
132+
</div>
133+
134+
{/* 지도 모달 */}
135+
{meeting.location && (
136+
<MapModal
137+
open={isMapModalOpen}
138+
onOpenChange={setIsMapModalOpen}
139+
location={meeting.location}
140+
/>
141+
)}
142+
</div>
143+
)
144+
}

src/features/meetings/components/PlaceList.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ export type PlaceListProps = {
1616
onPlaceHoverEnd?: () => void
1717
}
1818

19-
export function PlaceList({ places, onPlaceClick, onPlaceHover, onPlaceHoverEnd }: PlaceListProps) {
19+
export default function PlaceList({
20+
places,
21+
onPlaceClick,
22+
onPlaceHover,
23+
onPlaceHoverEnd,
24+
}: PlaceListProps) {
2025
if (places.length === 0) {
2126
return (
2227
<div className="w-[300px] flex items-center justify-center">

src/features/meetings/components/PlaceSearchModal.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
import { useKakaoMap } from '../hooks/useKakaoMap'
2121
import { useKakaoPlaceSearch } from '../hooks/useKakaoPlaceSearch'
2222
import type { KakaoPlace } from '../kakaoMap.types'
23-
import { PlaceList } from './PlaceList'
23+
import PlaceList from './PlaceList'
2424

2525
declare global {
2626
interface Window {
@@ -43,7 +43,11 @@ export type PlaceSearchModalProps = {
4343
}) => void
4444
}
4545

46-
export function PlaceSearchModal({ open, onOpenChange, onSelectPlace }: PlaceSearchModalProps) {
46+
export default function PlaceSearchModal({
47+
open,
48+
onOpenChange,
49+
onSelectPlace,
50+
}: PlaceSearchModalProps) {
4751
// 지도 관리
4852
const { mapElement, isInitialized, initializeMap, renderMarkers, setCenter, cleanup } =
4953
useKakaoMap()
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
export { default as MapModal } from './MapModal'
12
export { default as MeetingApprovalItem } from './MeetingApprovalItem'
23
export { default as MeetingApprovalList } from './MeetingApprovalList'
3-
export * from './PlaceList'
4-
export * from './PlaceSearchModal'
4+
export { default as MeetingDetailButton } from './MeetingDetailButton'
5+
export { MeetingDetailHeader } from './MeetingDetailHeader'
6+
export { MeetingDetailInfo } from './MeetingDetailInfo'
7+
export { default as PlaceList } from './PlaceList'
8+
export { default as PlaceSearchModal } from './PlaceSearchModal'
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
export * from './meetingQueryKeys'
2+
export * from './useCancelJoinMeeting'
23
export * from './useConfirmMeeting'
34
export * from './useCreateMeeting'
45
export * from './useDeleteMeeting'
6+
export * from './useJoinMeeting'
57
export * from './useKakaoMap'
68
export * from './useKakaoPlaceSearch'
79
export * from './useMeetingApprovals'
810
export * from './useMeetingApprovalsCount'
11+
export * from './useMeetingDetail'
912
export * from './useMeetingForm'
1013
export * from './useRejectMeeting'

src/features/meetings/hooks/meetingQueryKeys.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,8 @@ export const meetingQueryKeys = {
2222
approvalCounts: () => [...meetingQueryKeys.approvals(), 'count'] as const,
2323
approvalCount: (gatheringId: number, status: GetMeetingApprovalsParams['status']) =>
2424
[...meetingQueryKeys.approvalCounts(), gatheringId, status] as const,
25+
26+
// 약속 상세 관련
27+
details: () => [...meetingQueryKeys.all, 'detail'] as const,
28+
detail: (meetingId: number) => [...meetingQueryKeys.details(), meetingId] as const,
2529
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @file useCancelJoinMeeting.ts
3+
* @description 약속 참가취소 mutation 훅
4+
*/
5+
6+
import { useMutation, useQueryClient } from '@tanstack/react-query'
7+
8+
import { ApiError } from '@/api/errors'
9+
import type { ApiResponse } from '@/api/types'
10+
import { cancelJoinMeeting } from '@/features/meetings'
11+
12+
import { meetingQueryKeys } from './meetingQueryKeys'
13+
14+
/**
15+
* 약속 참가취소 mutation 훅
16+
*
17+
* @description
18+
* 약속 참가를 취소하고 관련 쿼리 캐시를 무효화합니다.
19+
* - 약속 상세 캐시 무효화
20+
*
21+
*/
22+
export const useCancelJoinMeeting = () => {
23+
const queryClient = useQueryClient()
24+
25+
return useMutation<ApiResponse<number>, ApiError, number>({
26+
mutationFn: (meetingId: number) => cancelJoinMeeting(meetingId),
27+
onSuccess: (data, variables) => {
28+
void data // 사용하지 않는 파라미터
29+
// 약속 상세 캐시 무효화
30+
queryClient.invalidateQueries({
31+
queryKey: meetingQueryKeys.detail(variables),
32+
})
33+
},
34+
})
35+
}

0 commit comments

Comments
 (0)