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
4 changes: 4 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>독크독크</title>
<script
type="text/javascript"
src="//dapi.kakao.com/v2/maps/sdk.js?appkey=%VITE_KAKAO_MAP_KEY%&libraries=services"
></script>
</head>
<body>
<div id="root"></div>
Expand Down
48 changes: 48 additions & 0 deletions src/features/meetings/components/PlaceList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* @file PlaceList.tsx
* @description 장소 검색 결과 목록 컴포넌트
*/

import type { KakaoPlace } from '../kakaoMap.types'

export type PlaceListProps = {
/** 장소 목록 */
places: KakaoPlace[]
/** 장소 클릭 핸들러 */
onPlaceClick: (place: KakaoPlace) => void
/** 장소 hover 핸들러 */
onPlaceHover?: (place: KakaoPlace, index: number) => void
/** 장소 hover 종료 핸들러 */
onPlaceHoverEnd?: () => void
}

export function PlaceList({ places, onPlaceClick, onPlaceHover, onPlaceHoverEnd }: PlaceListProps) {
if (places.length === 0) {
return (
<div className="w-[300px] flex items-center justify-center">
<p className="text-center text-grey-600 typo-body3">검색 결과가 없습니다</p>
</div>
)
}

return (
<div className="w-[300px] flex flex-col gap-xtiny overflow-y-auto custom-scroll">
{places.map((place, index) => (
<button
key={place.id}
type="button"
onClick={() => onPlaceClick(place)}
onMouseEnter={() => onPlaceHover?.(place, index)}
onMouseLeave={onPlaceHoverEnd}
className="text-left transition-colors bg-white border p-small rounded-small border-grey-300 hover:bg-grey-50"
>
<p className="text-black typo-subtitle5 mb-xtiny">{place.place_name}</p>
<p className="typo-body4 text-grey-600 line-clamp-2">
{place.road_address_name || place.address_name}
</p>
{place.phone && <p className="typo-body4 text-grey-500 mt-xtiny">{place.phone}</p>}
</button>
))}
</div>
)
}
152 changes: 152 additions & 0 deletions src/features/meetings/components/PlaceSearchModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* @file PlaceSearchModal.tsx
* @description 카카오 장소 검색 모달 컴포넌트
*/

import { Search } from 'lucide-react'
import { useEffect, useRef } from 'react'

import {
Button,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/shared/ui'

import { useKakaoMap } from '../hooks/useKakaoMap'
import { useKakaoPlaceSearch } from '../hooks/useKakaoPlaceSearch'
import type { KakaoPlace } from '../kakaoMap.types'
import { PlaceList } from './PlaceList'

declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
kakao: any
}
}

export type PlaceSearchModalProps = {
/** 모달 열림 상태 */
open: boolean
/** 모달 열림 상태 변경 핸들러 */
onOpenChange: (open: boolean) => void
/** 장소 선택 핸들러 */
onSelectPlace: (place: {
name: string
address: string
latitude: number
longitude: number
}) => void
}

export function PlaceSearchModal({ open, onOpenChange, onSelectPlace }: PlaceSearchModalProps) {
// 지도 관리
const { mapElement, isInitialized, initializeMap, renderMarkers, setCenter, cleanup } =
useKakaoMap()

// 장소 검색 관리
const keywordRef = useRef<HTMLInputElement>(null)
const { places, search, reset } = useKakaoPlaceSearch({
onSearchSuccess: renderMarkers,
})

// 모달 열릴 때 지도 초기화
useEffect(() => {
if (open && !isInitialized) {
initializeMap()
}
}, [open, isInitialized, initializeMap])

// 검색 실행
const handleSearch = () => {
const keyword = keywordRef.current?.value || ''
search(keyword)
}

// Enter 키 처리
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
}
}

// 장소 선택
const handlePlaceClick = (place: KakaoPlace) => {
setCenter(Number(place.y), Number(place.x))

onSelectPlace({
name: place.place_name,
address: place.road_address_name || place.address_name,
latitude: Number(place.y),
longitude: Number(place.x),
})

onOpenChange(false)
reset()
cleanup()
}

// 모달 닫기
const handleClose = () => {
onOpenChange(false)
reset()
cleanup()
}

return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent variant="wide" onEscapeKeyDown={handleClose} onPointerDownOutside={handleClose}>
<ModalHeader>
<ModalTitle>장소 검색</ModalTitle>
</ModalHeader>

<ModalBody className="flex flex-col gap-base">
<div className="flex gap-xsmall">
<Input
placeholder="장소 또는 주소를 검색해주세요"
ref={keywordRef}
onKeyUp={handleKeyUp}
className="flex-1"
/>
<Button onClick={handleSearch} size="large" type="button">
<Search size={18} className="mr-tiny" />
검색
</Button>
</div>

<div className="flex gap-base h-100">
{/* 지도 영역 */}
<div className="relative flex-1">
<div ref={mapElement} className="w-full h-full rounded-small bg-grey-100" />

{/* 검색 전 안내 메시지 오버레이 */}
{!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center rounded-small bg-grey-50">
<div className="text-center text-grey-400">
<Search size={48} className="mx-auto mb-base opacity-30" />
<p className="text-lg font-medium mb-xsmall">장소를 검색하면</p>
<p className="text-sm">지도에 표시됩니다</p>
</div>
</div>
)}
</div>

{/* 장소 리스트 */}
<PlaceList places={places} onPlaceClick={handlePlaceClick} />
</div>
</ModalBody>

<ModalFooter>
<Button variant="secondary" outline onClick={handleClose}>
취소
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
2 changes: 2 additions & 0 deletions src/features/meetings/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { default as MeetingApprovalItem } from './MeetingApprovalItem'
export { default as MeetingApprovalList } from './MeetingApprovalList'
export * from './PlaceList'
export * from './PlaceSearchModal'
4 changes: 4 additions & 0 deletions src/features/meetings/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
export * from './meetingQueryKeys'
export * from './useConfirmMeeting'
export * from './useCreateMeeting'
export * from './useDeleteMeeting'
export * from './useKakaoMap'
export * from './useKakaoPlaceSearch'
export * from './useMeetingApprovals'
export * from './useMeetingApprovalsCount'
export * from './useMeetingForm'
export * from './useRejectMeeting'
50 changes: 50 additions & 0 deletions src/features/meetings/hooks/useCreateMeeting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* @file useCreateMeeting.ts
* @description 약속 생성 mutation 훅
*/

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

import { ApiError } from '@/api/errors'
import type { ApiResponse } from '@/api/types'
import {
createMeeting,
type CreateMeetingRequest,
type CreateMeetingResponse,
} from '@/features/meetings'

import { meetingQueryKeys } from './meetingQueryKeys'

/**
* 약속 생성 mutation 훅
*
* @description
* 새로운 약속을 생성하고 관련 쿼리 캐시를 무효화합니다.
* - 약속 승인 리스트 캐시 무효화
* - 약속 승인 카운트 캐시 무효화
*
* @example
* const createMutation = useCreateMeeting()
* createMutation.mutate({
* gatheringId: 1,
* bookId: 1,
* meetingName: '1월 독서 모임',
* meetingStartDate: '2025-02-01T14:00:00',
* meetingEndDate: '2025-02-01T16:00:00',
* maxParticipants: 10,
* place: '강남역 스타벅스'
* })
*/
export const useCreateMeeting = () => {
const queryClient = useQueryClient()

return useMutation<ApiResponse<CreateMeetingResponse>, ApiError, CreateMeetingRequest>({
mutationFn: (data: CreateMeetingRequest) => createMeeting(data),
onSuccess: () => {
// 약속 승인 관련 모든 캐시 무효화 (리스트 + 카운트)
queryClient.invalidateQueries({
queryKey: meetingQueryKeys.approvals(),
})
},
})
}
Loading
Loading