Skip to content
25 changes: 25 additions & 0 deletions src/features/meetings/components/MapModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { MeetingLocation } from '@/features/meetings/meetings.types'
import { Modal, ModalBody, ModalContent, ModalHeader, ModalTitle } from '@/shared/ui'

interface MapModalProps {
open: boolean
onOpenChange: (open: boolean) => void
location: MeetingLocation
}

export default function MapModal({ open, onOpenChange, location }: MapModalProps) {
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent variant="normal">
<ModalHeader>
<ModalTitle>{location.name}</ModalTitle>
</ModalHeader>
<ModalBody>
<div className="flex items-center justify-center h-full text-grey-600 typo-body2">
지도 API 연동 예정
</div>
</ModalBody>
</ModalContent>
</Modal>
)
}
86 changes: 86 additions & 0 deletions src/features/meetings/components/MeetingDetailButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useCancelJoinMeeting, useJoinMeeting } from '@/features/meetings/hooks'
import type { MeetingDetailActionStateType } from '@/features/meetings/meetings.types'
import { Button } from '@/shared/ui'
import { useGlobalModalStore } from '@/store'

interface MeetingDetailButtonProps {
buttonLabel: string
isEnabled: boolean
type: MeetingDetailActionStateType
meetingId: number
}

export default function MeetingDetailButton({
buttonLabel,
isEnabled,
type,
meetingId,
}: MeetingDetailButtonProps) {
const joinMutation = useJoinMeeting()
const cancelJoinMutation = useCancelJoinMeeting()
const { openError, openConfirm } = useGlobalModalStore()

const isPending = joinMutation.isPending || cancelJoinMutation.isPending

const handleClick = async () => {
if (!isEnabled || isPending) return

// 약속 수정 - 페이지 이동 예정 (TODO)
if (type === 'CAN_EDIT') {
// 페이지 이동 로직 추가 예정
return
}

// 약속 참가신청
if (type === 'CAN_JOIN') {
const confirmed = await openConfirm('참가 신청', '약속 참가 신청을 하시겠습니까?')
if (!confirmed) return

joinMutation.mutate(meetingId, {
onSuccess: () => {
alert('참가 신청이 완료되었습니다.')
},
onError: (error) => {
openError('에러', error.userMessage)
},
})
return
}

// 약속 참가취소
if (type === 'CAN_CANCEL') {
const confirmed = await openConfirm('참가 신청 취소', '약속 참가 신청을 취소하시겠습니까?')
if (!confirmed) return

cancelJoinMutation.mutate(meetingId, {
onSuccess: () => {
alert('참가 취소가 완료되었습니다.')
},
onError: (error) => {
openError('에러', error.userMessage)
},
})
return
}
}

return (
<div>
<Button
size="medium"
className="w-full"
disabled={!isEnabled || isPending}
onClick={handleClick}
>
{buttonLabel}
</Button>
{isEnabled && (
<p className="text-grey-700 typo-body6 pt-tiny">
{type === 'EDIT_TIME_EXPIRED' && '약속 24시간 전까지만 약속 정보를 수정할 수 있어요'}
{(type === 'CANCEL_TIME_EXPIRED' || type === 'JOIN_TIME_EXPIRED') &&
'* 약속 24시간 전까지만 참가 신청 및 취소가 가능해요'}
</p>
)}
</div>
)
}
27 changes: 27 additions & 0 deletions src/features/meetings/components/MeetingDetailHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { MeetingProgressStatus } from '@/features/meetings/meetings.types'
import { Badge } from '@/shared/ui'

interface MeetingDetailHeaderProps {
children: string
progressStatus: MeetingProgressStatus
}
type ProgressBadge = {
text: '약속 전' | '약속 중' | '약속 후'
color: 'yellow' | 'blue' | 'red'
}
export function MeetingDetailHeader({ children, progressStatus }: MeetingDetailHeaderProps) {
const progressStatusLabelMap: Record<MeetingProgressStatus, ProgressBadge> = {
PRE: { text: '약속 전', color: 'yellow' },
ONGOING: { text: '약속 중', color: 'red' },
POST: { text: '약속 후', color: 'blue' },
}
const { text, color } = progressStatusLabelMap[progressStatus]
return (
<div className="flex items-start border-b gap-small border-b-grey-300 pb-[10px]">
<h3 className="text-black typo-heading3">{children}</h3>
<Badge size="small" color={color}>
{text}
</Badge>
</div>
)
}
144 changes: 144 additions & 0 deletions src/features/meetings/components/MeetingDetailInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { MapPin } from 'lucide-react'
import { useState } from 'react'

import MapModal from '@/features/meetings/components/MapModal'
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
TextButton,
} from '@/shared/ui'

import type { GetMeetingDetailResponse } from '../meetings.types'

const MAX_DISPLAYED_AVATARS = 4
const DT_VARIANTS = 'w-[68px] text-grey-600 typo-caption1'

interface MeetingDetailInfoProps {
meeting: GetMeetingDetailResponse
}

export function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) {
const [isMapModalOpen, setIsMapModalOpen] = useState(false)

const leader = meeting.participants.members.find((member) => member.role === 'LEADER')
const members = meeting.participants.members.filter((member) => member.role === 'MEMBER')
const displayedMembers = members.slice(0, MAX_DISPLAYED_AVATARS)
const remainingMembers = members.slice(MAX_DISPLAYED_AVATARS)
const hasRegularMembers = members.length > 0
const hasRemainingMembers = remainingMembers.length > 0

const [startDate, endDate] = meeting.schedule.displayDate.split(' ~ ')

return (
<div className="w-[300px] flex-none flex flex-col gap-base">
<div className="flex flex-col gap-medium">
{/* 도서 */}
<dl className="flex gap-base">
<dt className={DT_VARIANTS}>도서</dt>
<dd className="flex flex-col gap-xtiny">
<p className="text-black typo-body3">{meeting.book.bookName}</p>
<div className="w-[120px] h-[170px] overflow-hidden rounded">
<img
src={meeting.book.thumbnail}
alt={meeting.book.bookName}
className="object-cover w-full h-full"
/>
</div>
</dd>
</dl>

{/* 참가인원 */}
<dl className="flex gap-base">
<dt className={DT_VARIANTS}>참가인원</dt>
<dd className="flex flex-col text-black typo-body3 gap-small">
<p>
{meeting.participants.currentCount}{' '}
<span className="typo-caption2 text-grey-600">/{meeting.participants.maxCount}</span>
</p>

{/* 약속장 */}
{leader && (
<div className="flex items-center gap-small">
<p>약속장</p>
<Avatar variant="host">
<AvatarImage src={leader.profileImageUrl} alt={leader.nickname} />
<AvatarFallback>{leader.nickname[0]}</AvatarFallback>
</Avatar>
</div>
)}

{/* 멤버 */}
{hasRegularMembers && (
<div className="flex items-center gap-small">
<p>멤버</p>
<AvatarGroup>
{displayedMembers.map((member) => (
<Avatar key={member.userId}>
<AvatarImage src={member.profileImageUrl} alt={member.nickname} />
<AvatarFallback>{member.nickname[0]}</AvatarFallback>
</Avatar>
))}
{hasRemainingMembers && (
<AvatarGroupCount
items={remainingMembers.map((member) => ({
id: String(member.userId),
name: member.nickname,
src: member.profileImageUrl,
fallbackText: member.nickname[0],
}))}
preview={{
name: remainingMembers[0].nickname,
src: remainingMembers[0].profileImageUrl,
fallbackText: remainingMembers[0].nickname[0],
}}
>
+{remainingMembers.length}
</AvatarGroupCount>
)}
</AvatarGroup>
</div>
)}
</dd>
</dl>

{/* 날짜 및 시간 */}
<dl className="flex gap-base">
<dt className={DT_VARIANTS}>날짜 및 시간</dt>
<dd className="text-black typo-body3">
<p>{startDate}</p>
<p>~ {endDate}</p>
</dd>
</dl>

{/* 장소 */}
<dl className="flex gap-base">
<dt className={DT_VARIANTS}>장소</dt>
<dd>
{meeting.location && (
<TextButton
size="medium"
icon={MapPin}
className="text-black typo-body3 [&_svg]:text-grey-600"
onClick={() => setIsMapModalOpen(true)}
>
{meeting.location.name}
</TextButton>
)}
</dd>
</dl>
</div>

{/* 지도 모달 */}
{meeting.location && (
<MapModal
open={isMapModalOpen}
onOpenChange={setIsMapModalOpen}
location={meeting.location}
/>
)}
</div>
)
}
7 changes: 6 additions & 1 deletion src/features/meetings/components/PlaceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ export type PlaceListProps = {
onPlaceHoverEnd?: () => void
}

export function PlaceList({ places, onPlaceClick, onPlaceHover, onPlaceHoverEnd }: PlaceListProps) {
export default function PlaceList({
places,
onPlaceClick,
onPlaceHover,
onPlaceHoverEnd,
}: PlaceListProps) {
if (places.length === 0) {
return (
<div className="w-[300px] flex items-center justify-center">
Expand Down
8 changes: 6 additions & 2 deletions src/features/meetings/components/PlaceSearchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
import { useKakaoMap } from '../hooks/useKakaoMap'
import { useKakaoPlaceSearch } from '../hooks/useKakaoPlaceSearch'
import type { KakaoPlace } from '../kakaoMap.types'
import { PlaceList } from './PlaceList'
import PlaceList from './PlaceList'

declare global {
interface Window {
Expand All @@ -43,7 +43,11 @@ export type PlaceSearchModalProps = {
}) => void
}

export function PlaceSearchModal({ open, onOpenChange, onSelectPlace }: PlaceSearchModalProps) {
export default function PlaceSearchModal({
open,
onOpenChange,
onSelectPlace,
}: PlaceSearchModalProps) {
// 지도 관리
const { mapElement, isInitialized, initializeMap, renderMarkers, setCenter, cleanup } =
useKakaoMap()
Expand Down
8 changes: 6 additions & 2 deletions src/features/meetings/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export { default as MapModal } from './MapModal'
export { default as MeetingApprovalItem } from './MeetingApprovalItem'
export { default as MeetingApprovalList } from './MeetingApprovalList'
export * from './PlaceList'
export * from './PlaceSearchModal'
export { default as MeetingDetailButton } from './MeetingDetailButton'
export { MeetingDetailHeader } from './MeetingDetailHeader'
export { MeetingDetailInfo } from './MeetingDetailInfo'
export { default as PlaceList } from './PlaceList'
export { default as PlaceSearchModal } from './PlaceSearchModal'
3 changes: 3 additions & 0 deletions src/features/meetings/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
export * from './meetingQueryKeys'
export * from './useCancelJoinMeeting'
export * from './useConfirmMeeting'
export * from './useCreateMeeting'
export * from './useDeleteMeeting'
export * from './useJoinMeeting'
export * from './useKakaoMap'
export * from './useKakaoPlaceSearch'
export * from './useMeetingApprovals'
export * from './useMeetingApprovalsCount'
export * from './useMeetingDetail'
export * from './useMeetingForm'
export * from './useRejectMeeting'
4 changes: 4 additions & 0 deletions src/features/meetings/hooks/meetingQueryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@ export const meetingQueryKeys = {
approvalCounts: () => [...meetingQueryKeys.approvals(), 'count'] as const,
approvalCount: (gatheringId: number, status: GetMeetingApprovalsParams['status']) =>
[...meetingQueryKeys.approvalCounts(), gatheringId, status] as const,

// 약속 상세 관련
details: () => [...meetingQueryKeys.all, 'detail'] as const,
detail: (meetingId: number) => [...meetingQueryKeys.details(), meetingId] as const,
}
35 changes: 35 additions & 0 deletions src/features/meetings/hooks/useCancelJoinMeeting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* @file useCancelJoinMeeting.ts
* @description 약속 참가취소 mutation 훅
*/

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

import { ApiError } from '@/api/errors'
import type { ApiResponse } from '@/api/types'
import { cancelJoinMeeting } from '@/features/meetings'

import { meetingQueryKeys } from './meetingQueryKeys'

/**
* 약속 참가취소 mutation 훅
*
* @description
* 약속 참가를 취소하고 관련 쿼리 캐시를 무효화합니다.
* - 약속 상세 캐시 무효화
*
*/
export const useCancelJoinMeeting = () => {
const queryClient = useQueryClient()

return useMutation<ApiResponse<number>, ApiError, number>({
mutationFn: (meetingId: number) => cancelJoinMeeting(meetingId),
onSuccess: (data, variables) => {
void data // 사용하지 않는 파라미터
// 약속 상세 캐시 무효화
queryClient.invalidateQueries({
queryKey: meetingQueryKeys.detail(variables),
})
},
})
}
Loading
Loading