diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js
index 94db54a..818ae50 100644
--- a/backend/src/services/schedule.js
+++ b/backend/src/services/schedule.js
@@ -354,6 +354,109 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
} else {
result.venue = null;
}
+ } else if (s.category_id === CATEGORY_IDS.CONCERT) {
+ // 콘서트: 시리즈/포스터/장소/세트리스트(곡별 멤버)/굿즈/다른 회차
+ const [conRows] = await db.query(`
+ SELECT sc.id AS concert_id, sc.series_id,
+ cs.title AS series_title, cs.poster_id,
+ cv.id AS venue_id, cv.name AS venue_name, cv.address AS venue_address,
+ cv.country AS venue_country, cv.lat AS venue_lat, cv.lng AS venue_lng
+ FROM schedule_concert sc
+ LEFT JOIN concert_series cs ON sc.series_id = cs.id
+ LEFT JOIN concert_venues cv ON sc.venue_id = cv.id
+ WHERE sc.schedule_id = ?
+ `, [id]);
+
+ if (conRows.length > 0) {
+ const con = conRows[0];
+ result.seriesId = con.series_id;
+ result.seriesTitle = con.series_title || null;
+ result.activeMemberCount = activeMemberCount; // 유닛/솔로 판별용
+
+ // 포스터
+ if (con.poster_id) {
+ const [posterRows] = await db.query(
+ 'SELECT original_url, medium_url, thumb_url FROM images WHERE id = ?',
+ [con.poster_id]
+ );
+ result.poster = posterRows.length > 0 ? {
+ originalUrl: posterRows[0].original_url,
+ mediumUrl: posterRows[0].medium_url,
+ thumbUrl: posterRows[0].thumb_url,
+ } : null;
+ } else {
+ result.poster = null;
+ }
+
+ // 장소
+ result.venue = con.venue_id ? {
+ id: con.venue_id,
+ name: con.venue_name,
+ address: con.venue_address,
+ country: con.venue_country,
+ lat: con.venue_lat,
+ lng: con.venue_lng,
+ } : null;
+
+ // 세트리스트 (이 회차) + 곡별 멤버
+ const [songs] = await db.query(
+ `SELECT id, order_num, song_name, album_name
+ FROM concert_setlists WHERE concert_id = ? ORDER BY order_num ASC`,
+ [con.concert_id]
+ );
+ const memberMap = {};
+ if (songs.length > 0) {
+ const songIds = songs.map(x => x.id);
+ const [allMem] = await db.query(
+ `SELECT csm.setlist_id, m.id, m.name
+ FROM concert_setlist_members csm JOIN members m ON csm.member_id = m.id
+ WHERE csm.setlist_id IN (?) ORDER BY m.id`,
+ [songIds]
+ );
+ for (const row of allMem) {
+ (memberMap[row.setlist_id] ||= []).push({ id: row.id, name: row.name });
+ }
+ }
+ result.setlist = songs.map(song => ({
+ id: song.id,
+ order: song.order_num,
+ songName: song.song_name,
+ albumName: song.album_name || null,
+ members: memberMap[song.id] || [],
+ }));
+
+ // 굿즈 + 다른 회차 (시리즈 기준)
+ if (con.series_id) {
+ const [md] = await db.query(
+ `SELECT csm.id, i.original_url, i.medium_url, i.thumb_url
+ FROM concert_series_md csm JOIN images i ON csm.image_id = i.id
+ WHERE csm.series_id = ? ORDER BY csm.sort_order ASC`,
+ [con.series_id]
+ );
+ result.merchandise = md.map(x => ({
+ id: x.id,
+ originalUrl: x.original_url,
+ mediumUrl: x.medium_url,
+ thumbUrl: x.thumb_url,
+ }));
+
+ const [rounds] = await db.query(
+ `SELECT s2.id AS schedule_id, s2.date, s2.time
+ FROM schedule_concert sc2 JOIN schedules s2 ON sc2.schedule_id = s2.id
+ WHERE sc2.series_id = ? AND s2.id != ?
+ ORDER BY s2.date ASC, s2.time ASC`,
+ [con.series_id, id]
+ );
+ result.otherRounds = rounds.map(r => ({
+ scheduleId: r.schedule_id,
+ date: normalizeDate(r.date),
+ time: r.time ? r.time.substring(0, 5) : null,
+ }));
+ } else {
+ result.merchandise = [];
+ result.otherRounds = [];
+ }
+ }
}
return result;
diff --git a/frontend/src/components/common/Lightbox.jsx b/frontend/src/components/common/Lightbox.jsx
index 9d794a8..52ae13b 100644
--- a/frontend/src/components/common/Lightbox.jsx
+++ b/frontend/src/components/common/Lightbox.jsx
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, memo } from 'react';
+import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import LightboxIndicator from './LightboxIndicator';
@@ -131,7 +132,7 @@ function Lightbox({
const photoMembers = currentPhoto?.members;
const hasMembers = photoMembers && String(photoMembers).trim();
- return (
+ return createPortal(
{isOpen && images.length > 0 && (
)}
-
+ ,
+ document.body
);
}
diff --git a/frontend/src/components/common/MobileLightbox.jsx b/frontend/src/components/common/MobileLightbox.jsx
index e9c6d73..6ac481b 100644
--- a/frontend/src/components/common/MobileLightbox.jsx
+++ b/frontend/src/components/common/MobileLightbox.jsx
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
+import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Download, Info, Users, Tag } from 'lucide-react';
import { Swiper, SwiperSlide } from 'swiper/react';
@@ -112,7 +113,7 @@ function MobileLightbox({
// 이미지가 없으면 렌더링하지 않음
if (!images?.length) return null;
- return (
+ return createPortal(
{isOpen && (
)}
-
+ ,
+ document.body
);
}
diff --git a/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx b/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx
index ebd10e0..3b595fe 100644
--- a/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx
+++ b/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx
@@ -1,10 +1,11 @@
-import { useParams, Link } from 'react-router-dom';
+import { createPortal } from 'react-dom';
+import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useEffect, useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
-import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight, Tv, ExternalLink, Play, MapPin, PartyPopper } from 'lucide-react';
+import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight, ChevronDown, Tv, ExternalLink, Play, MapPin, PartyPopper, ShoppingBag, Ticket, Disc3 } from 'lucide-react';
import { getSchedule } from '@/api';
-import { KakaoMap } from '@/components/common';
+import { KakaoMap, MobileLightbox } from '@/components/common';
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
import Birthday from './Birthday';
@@ -807,6 +808,275 @@ function MobileVarietySection({ schedule }) {
);
}
+/**
+ * Mobile 콘서트 섹션
+ */
+function MobileConcertSection({ schedule }) {
+ const navigate = useNavigate();
+ const poster = schedule.poster || null;
+ const venue = schedule.venue || null;
+ const setlist = schedule.setlist || [];
+ const merchandise = schedule.merchandise || [];
+ const otherRounds = schedule.otherRounds || [];
+ const activeCount = schedule.activeMemberCount || 5;
+ const ACCENT = '#f97316';
+ const COLLAPSE_COUNT = 5;
+ const kakaoMapUrl = venue && venue.lat && venue.lng
+ ? `https://map.kakao.com/link/map/${encodeURIComponent(venue.name)},${venue.lat},${venue.lng}`
+ : null;
+ const isUnit = (m) => m.length > 0 && m.length < activeCount;
+ const posterImg = poster ? (poster.mediumUrl || poster.originalUrl) : null;
+
+ const [mapOpen, setMapOpen] = useState(false);
+ const [roundsOpen, setRoundsOpen] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+ const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 });
+ const openLightbox = (images, index) => {
+ setLightbox({ open: true, images, index });
+ window.history.pushState({ lightbox: true }, '');
+ };
+
+ // 세트리스트 높이 (1열, 행 52px) — 펼치기/접기 애니메이션
+ const ROW_H = 52;
+ const collapsible = setlist.length > COLLAPSE_COUNT;
+ const collapsedHeight = COLLAPSE_COUNT * ROW_H;
+ const fullHeight = setlist.length * ROW_H;
+
+ const days = ['일', '월', '화', '수', '목', '금', '토'];
+ const shortDate = (ds) => {
+ const d = new Date(ds);
+ return `${d.getMonth() + 1}.${d.getDate()} (${days[d.getDay()]})`;
+ };
+ const allRounds = [
+ { scheduleId: schedule.id, date: schedule.date, time: schedule.time, current: true },
+ ...otherRounds.map((r) => ({ ...r, current: false })),
+ ].sort((a, b) => a.date.localeCompare(b.date));
+ const hasMultiRounds = allRounds.length > 1;
+ const currentRoundNum = allRounds.findIndex((r) => r.current) + 1;
+
+ return (
+
+ {/* 히어로 (모바일 전용 세로형) */}
+
+ {posterImg ? (
+
+

+
+
+ ) : (
+
+ )}
+
+
+ {/* 포스터 (중앙, 크게) */}
+
+
+ {/* 제목 */}
+
+ {decodeHtmlEntities(schedule.title)}
+
+
+ {/* 날짜(+회차) · 장소 */}
+
+
+
+ {shortDate(schedule.date)}{schedule.time && ` · ${formatTime(schedule.time)}`}
+ {hasMultiRounds && (
+
+ )}
+
+ {venue && (
+
+ )}
+
+
+
+
+ {/* 세트리스트 */}
+ {setlist.length > 0 && (
+
+
+
+
세트리스트
+ {setlist.length}곡
+
+
+ {setlist.map((song, idx) => (
+
+
+ {String(idx + 1).padStart(2, '0')}
+
+
+
{song.songName}
+ {song.albumName &&
{song.albumName}
}
+
+ {isUnit(song.members) && (
+
+ {song.members.map((m) => (
+ {m.name}
+ ))}
+
+ )}
+
+ ))}
+
+ {collapsible && (
+
+ )}
+
+ )}
+
+ {/* 굿즈 (원본 비율 유지, masonry) */}
+ {merchandise.length > 0 && (
+
+
+
+
MD
+ {merchandise.length}
+
+
+ {merchandise.map((md, idx) => (
+
+ ))}
+
+
+ )}
+
+ {/* 회차 선택 바텀시트 */}
+ {createPortal(
+
+ {roundsOpen && hasMultiRounds && (
+
+
setRoundsOpen(false)} />
+
+
+ 공연 일정
+
+ {allRounds.map((round, i) => (
+ round.current ? (
+
+
+ {i + 1}회차
+ {shortDate(round.date)}
+
+ {round.time && {formatTime(round.time)}}
+
+ ) : (
+
+ )
+ ))}
+
+
+
+ )}
+ ,
+ document.body
+ )}
+
+ {/* 장소 지도 다이얼로그 */}
+ {createPortal(
+
+ {mapOpen && venue && (
+
+
setMapOpen(false)} />
+
+
+
+
+
+
{venue.name}
+ {venue.address &&
{venue.address}
}
+
+
+
+ {kakaoMapUrl && (
+
+ 카카오맵에서 길찾기
+
+ )}
+
+
+ )}
+ ,
+ document.body
+ )}
+
+ {/* Lightbox */}
+
setLightbox((prev) => ({ ...prev, open: false }))}
+ onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))}
+ showCounter={lightbox.images.length > 1}
+ showDownload
+ />
+
+ );
+}
+
/**
* Mobile 기본 섹션
*/
@@ -967,6 +1237,8 @@ function MobileScheduleDetail() {
return ;
case '행사':
return ;
+ case '콘서트':
+ return ;
default:
return ;
}
diff --git a/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx b/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx
index b8b7e40..b992486 100644
--- a/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx
+++ b/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx
@@ -5,7 +5,7 @@ import { Calendar, ChevronRight } from 'lucide-react';
import { getSchedule } from '@/api';
// 섹션 컴포넌트들
-import { YoutubeSection, XSection, VarietySection, EventSection, DefaultSection, decodeHtmlEntities } from './sections';
+import { YoutubeSection, XSection, VarietySection, EventSection, ConcertSection, DefaultSection, decodeHtmlEntities } from './sections';
import Birthday from './Birthday';
/**
@@ -157,6 +157,8 @@ function PCScheduleDetail() {
return ;
case '행사':
return ;
+ case '콘서트':
+ return ;
default:
return ;
}
@@ -166,11 +168,12 @@ function PCScheduleDetail() {
const isX = categoryName === 'X';
const isVariety = categoryName === '예능';
const isEvent = categoryName === '행사';
- const hasCustomLayout = isYoutube || isX || isVariety || isEvent;
+ const isConcert = categoryName === '콘서트';
+ const hasCustomLayout = isYoutube || isX || isVariety || isEvent || isConcert;
return (
-
+
{/* 브레드크럼 네비게이션 */}
setLightbox({ open: true, images, index });
+ const isUnit = (m) => m.length > 0 && m.length < activeCount;
+ const posterImg = poster ? (poster.mediumUrl || poster.originalUrl) : null;
+
+ const allRounds = [
+ { scheduleId: schedule.id, date: schedule.date, time: schedule.time, current: true },
+ ...otherRounds.map((r) => ({ ...r, current: false })),
+ ].sort((a, b) => a.date.localeCompare(b.date));
+ const hasMultiRounds = allRounds.length > 1;
+ const currentRoundNum = allRounds.findIndex((r) => r.current) + 1;
+
+ // 세트리스트 높이 (2단 그리드, 행 60px) — 펼치기/접기 애니메이션용
+ const ROW_H = 60;
+ const collapsible = setlist.length > COLLAPSE_COUNT;
+ const collapsedHeight = Math.ceil(COLLAPSE_COUNT / 2) * ROW_H;
+ const fullHeight = Math.ceil(setlist.length / 2) * ROW_H;
+
+ return (
+
+ {/* ===== 히어로 ===== */}
+
+ {posterImg ? (
+
+

+
+
+ ) : (
+
+ )}
+
+
+ {/* 포스터 */}
+
+ {posterImg ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* 정보 */}
+
+
+
+
+ Concert
+
+
+
+
+
+ {decodeHtmlEntities(schedule.title)}
+
+
+ {/* 날짜/시간/장소 (강조) */}
+
+ {/* 날짜 + 회차 드롭다운 */}
+
+
+
+
+ {roundsOpen && hasMultiRounds && (
+ <>
+ setRoundsOpen(false)} />
+
+ {allRounds.map((round, i) => (
+
+ ))}
+
+ >
+ )}
+
+
+
+ {/* 장소 (클릭 → 지도 다이얼로그) */}
+ {venue && (
+
+ )}
+
+
+
+
+
+ {/* ===== 세트리스트 (2단) ===== */}
+ {setlist.length > 0 && (
+
+
+
+
+
+
+
{setlist.length}곡
+
+
+
+
+
+ {setlist.map((song, idx) => (
+
+
+ {String(idx + 1).padStart(2, '0')}
+
+
+
{song.songName}
+ {song.albumName &&
{song.albumName}
}
+
+ {isUnit(song.members) && (
+
+ {song.members.map((m) => (
+
+ {m.name}
+
+ ))}
+
+ )}
+
+ ))}
+
+
+
+
+ {/* 펼치기/접기 */}
+ {collapsible && (
+
+
+
+ )}
+
+ )}
+
+ {/* ===== 굿즈 (MD) — 원본 비율 유지, masonry ===== */}
+ {merchandise.length > 0 && (
+
+
+
+
MD
+ {merchandise.length}
+
+
+ {merchandise.map((md, idx) => (
+
+ ))}
+
+
+ )}
+
+ {/* ===== 장소 지도 다이얼로그 ===== */}
+
+ {mapOpen && venue && (
+
+
setMapOpen(false)} />
+
+
+
+
+
+
{venue.name}
+ {venue.address &&
{venue.address}
}
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Lightbox */}
+
setLightbox((prev) => ({ ...prev, open: false }))}
+ onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))}
+ showCounter={lightbox.images.length > 1}
+ showDownload
+ />
+
+ );
+}
+
+export default ConcertSection;
diff --git a/frontend/src/pages/pc/public/schedule/sections/index.js b/frontend/src/pages/pc/public/schedule/sections/index.js
index bdbda57..35b128b 100644
--- a/frontend/src/pages/pc/public/schedule/sections/index.js
+++ b/frontend/src/pages/pc/public/schedule/sections/index.js
@@ -2,5 +2,6 @@ export { default as YoutubeSection } from './YoutubeSection';
export { default as XSection } from './XSection';
export { default as VarietySection } from './VarietySection';
export { default as EventSection } from './EventSection';
+export { default as ConcertSection } from './ConcertSection';
export { default as DefaultSection } from './DefaultSection';
export * from './utils';