diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js index e72e41b..7768abc 100644 --- a/backend/routes/schedules.js +++ b/backend/routes/schedules.js @@ -155,15 +155,29 @@ router.get("/:id", async (req, res) => { return res.status(404).json({ error: "일정을 찾을 수 없습니다." }); } + const schedule = schedules[0]; + // 이미지 조회 const [images] = await pool.query( `SELECT image_url FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC`, [id] ); - - const schedule = schedules[0]; schedule.images = images.map((img) => img.image_url); + // 콘서트 카테고리(id=6)인 경우 같은 제목의 관련 일정들도 조회 + if (schedule.category_id === 6) { + const [relatedSchedules] = await pool.query( + ` + SELECT id, date, time + FROM schedules + WHERE title = ? AND category_id = 6 + ORDER BY date ASC, time ASC + `, + [schedule.title] + ); + schedule.related_dates = relatedSchedules; + } + res.json(schedule); } catch (error) { console.error("일정 조회 오류:", error); diff --git a/frontend/.env b/frontend/.env index 8f9f3d9..6400e98 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,2 +1,2 @@ -VITE_KAKAO_JS_KEY=5a626e19fbafb33b1eea26f162038ccb +VITE_KAKAO_JS_KEY=84b3c657c3de7d1ca89e1fa33455b8da VITE_KAKAO_REST_KEY=e7a5516bf6cb1b398857789ee2ea6eea diff --git a/frontend/src/pages/pc/public/ScheduleDetail.jsx b/frontend/src/pages/pc/public/ScheduleDetail.jsx index 11396cb..a590b91 100644 --- a/frontend/src/pages/pc/public/ScheduleDetail.jsx +++ b/frontend/src/pages/pc/public/ScheduleDetail.jsx @@ -1,12 +1,12 @@ import { useParams, Link } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { useEffect, useRef, useState } from 'react'; import { motion } from 'framer-motion'; import { Clock, Calendar, ExternalLink, ChevronRight, Link2, MapPin, Navigation } from 'lucide-react'; import { getSchedule, getXProfile } from '../../../api/public/schedules'; // 카카오맵 SDK 키 -const KAKAO_MAP_KEY = import.meta.env.VITE_KAKAO_MAP_KEY; +const KAKAO_MAP_KEY = import.meta.env.VITE_KAKAO_JS_KEY; // 카테고리 ID 상수 const CATEGORY_ID = { @@ -317,8 +317,15 @@ function XSection({ schedule }) { function KakaoMap({ lat, lng, name }) { const mapRef = useRef(null); const [mapLoaded, setMapLoaded] = useState(false); + const [mapError, setMapError] = useState(false); useEffect(() => { + // API 키가 없으면 에러 + if (!KAKAO_MAP_KEY) { + setMapError(true); + return; + } + // 카카오맵 SDK 동적 로드 if (!window.kakao?.maps) { const script = document.createElement('script'); @@ -326,6 +333,7 @@ function KakaoMap({ lat, lng, name }) { script.onload = () => { window.kakao.maps.load(() => setMapLoaded(true)); }; + script.onerror = () => setMapError(true); document.head.appendChild(script); } else { setMapLoaded(true); @@ -333,33 +341,55 @@ function KakaoMap({ lat, lng, name }) { }, []); useEffect(() => { - if (!mapLoaded || !mapRef.current) return; + if (!mapLoaded || !mapRef.current || mapError) return; - const position = new window.kakao.maps.LatLng(lat, lng); - const map = new window.kakao.maps.Map(mapRef.current, { - center: position, - level: 3, - }); - - // 마커 추가 - const marker = new window.kakao.maps.Marker({ - position, - map, - }); - - // 인포윈도우 추가 - if (name) { - const infowindow = new window.kakao.maps.InfoWindow({ - content: `
${name}
`, + try { + const position = new window.kakao.maps.LatLng(lat, lng); + const map = new window.kakao.maps.Map(mapRef.current, { + center: position, + level: 3, }); - infowindow.open(map, marker); + + // 마커 추가 + const marker = new window.kakao.maps.Marker({ + position, + map, + }); + + // 인포윈도우 추가 + if (name) { + const infowindow = new window.kakao.maps.InfoWindow({ + content: `
${name}
`, + }); + infowindow.open(map, marker); + } + } catch (e) { + setMapError(true); } - }, [mapLoaded, lat, lng, name]); + }, [mapLoaded, lat, lng, name, mapError]); + + // 에러 시 정적 이미지 또는 링크로 대체 + if (mapError) { + return ( + +
+ +

{name}

+

클릭하여 길찾기

+
+
+ ); + } return (
); } @@ -367,137 +397,204 @@ function KakaoMap({ lat, lng, name }) { // 콘서트 섹션 컴포넌트 function ConcertSection({ schedule }) { const hasLocation = schedule.location_lat && schedule.location_lng; + const hasPoster = schedule.images?.length > 0; + const relatedDates = schedule.related_dates || []; + const hasMultipleDates = relatedDates.length > 1; - // 날짜 범위 포맷팅 - const formatDateRange = () => { - const start = formatFullDate(schedule.date); - if (schedule.end_date && schedule.end_date !== schedule.date) { - const end = formatFullDate(schedule.end_date); - return `${start} ~ ${end}`; + // 개별 날짜 포맷팅 + const formatSingleDate = (dateStr, timeStr) => { + const date = new Date(dateStr); + const dayNames = ['일', '월', '화', '수', '목', '금', '토']; + const month = date.getMonth() + 1; + const day = date.getDate(); + const weekday = dayNames[date.getDay()]; + + let result = `${month}월 ${day}일 (${weekday})`; + if (timeStr) { + result += ` ${timeStr.slice(0, 5)}`; } - return start; + return result; }; return ( -
- {/* 포스터 이미지 */} - {schedule.images?.length > 0 && ( +
+ {/* 상단: 포스터 + 정보 카드 (가로 배치) */} +
+ {/* 포스터 이미지 */} + {hasPoster && ( + +
+ {schedule.title} +
+
+ + )} + + {/* 정보 카드 */} - {schedule.title} - - )} +
+ {/* 헤더 */} +
+

+ {decodeHtmlEntities(schedule.title)} +

+
- {/* 콘서트 정보 카드 */} - - {/* 제목 */} -
-

- {decodeHtmlEntities(schedule.title)} -

-
+ {/* 정보 목록 */} +
+ {/* 공연 일정 목록 */} +
+
+ +
+
+ {hasMultipleDates ? ( +
+ {relatedDates.map((item, index) => { + const isCurrentDate = item.id === schedule.id; + return ( + + + {index + 1}회차 + + {formatSingleDate(item.date, item.time)} + + ); + })} +
+ ) : ( +
+

+ {formatSingleDate(schedule.date, schedule.time)} +

+
+ )} +
+
- {/* 정보 목록 */} -
- {/* 날짜 */} -
- -
-

일시

-

{formatDateRange()}

- {schedule.time && ( -

{formatTime(schedule.time)} 시작

+ {/* 장소 */} + {schedule.location_name && ( +
+
+ +
+
+

{schedule.location_name}

+ {schedule.location_address && ( +

{schedule.location_address}

+ )} +
+
+ )} + + {/* 설명 */} + {schedule.description && ( +
+

+ {decodeHtmlEntities(schedule.description)} +

+
)}
-
- {/* 장소 */} - {schedule.location_name && ( -
- -
-

장소

-

{schedule.location_name}

- {schedule.location_address && ( -

{schedule.location_address}

- )} - {schedule.location_detail && ( -

{schedule.location_detail}

- )} -
-
- )} - - {/* 설명 */} - {schedule.description && ( -
-

- {decodeHtmlEntities(schedule.description)} -

-
- )} -
- - {/* 카카오맵 또는 장소 정보 */} - {schedule.location_name && ( -
- {hasLocation ? ( -
- + {/* 버튼 영역 */} + {schedule.source_url && ( + - ) : ( -
- -

{schedule.location_name}

- {schedule.location_address && ( -

{schedule.location_address}

- )} -
)}
- )} + +
- {/* 원본 링크 */} - {schedule.source_url && ( -
+ {/* 하단: 지도 */} + {schedule.location_name && hasLocation && ( + +
+
+
+ +
+
+

위치 안내

+

{schedule.location_name}

+
+
- - 상세 정보 보기 + + 길찾기
- )} -
+
+ +
+ + )} + + {/* 해외 공연 등 좌표 없는 경우 */} + {schedule.location_name && !hasLocation && ( + +
+ +
+

{schedule.location_name}

+ {schedule.location_address && ( +

{schedule.location_address}

+ )} + {schedule.location_detail && ( +

{schedule.location_detail}

+ )} +
+ )}
); } @@ -553,9 +650,10 @@ function DefaultSection({ schedule }) { function ScheduleDetail() { const { id } = useParams(); - const { data: schedule, isLoading, error } = useQuery({ + const { data: schedule, isLoading, error, isFetching } = useQuery({ queryKey: ['schedule', id], queryFn: () => getSchedule(id), + placeholderData: keepPreviousData, }); if (isLoading) { @@ -604,7 +702,7 @@ function ScheduleDetail() { return (
-
+
{/* 브레드크럼 네비게이션 */}