feat(concert): 콘서트 상세 페이지 추가 (몰입형 디자인)

기존 DefaultSection으로 빈약하게 표시되던 콘서트를 전용 상세 페이지로.

백엔드:
- getScheduleDetail 콘서트 분기: 포스터/장소/세트리스트(곡별 멤버)/굿즈/다른 회차

프론트엔드:
- ConcertSection(PC) / MobileConcertSection(모바일) 신규
- 블러 포스터 히어로 + 회차 드롭다운 + 장소 지도 다이얼로그
- 세트리스트 펼치기/접기 애니메이션, 유닛/솔로만 멤버 태그
- 굿즈 masonry 갤러리(라이트박스), 회차 전환은 history replace
- 모바일은 세로 중앙형 전용 히어로 + 바텀시트
- Lightbox/MobileLightbox를 createPortal로 변경 (오버레이 전체화면 보장)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-05-31 23:20:48 +09:00
parent dd5ef48592
commit 2ddc16d532
7 changed files with 719 additions and 10 deletions

View file

@ -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;

View file

@ -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(
<AnimatePresence>
{isOpen && images.length > 0 && (
<motion.div
@ -283,7 +284,8 @@ function Lightbox({
</div>
</motion.div>
)}
</AnimatePresence>
</AnimatePresence>,
document.body
);
}

View file

@ -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(
<AnimatePresence>
{isOpen && (
<motion.div
@ -261,7 +262,8 @@ function MobileLightbox({
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</AnimatePresence>,
document.body
);
}

View file

@ -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 (
<div className="space-y-3">
{/* 히어로 (모바일 전용 세로형) */}
<div className="relative rounded-2xl overflow-hidden shadow-lg">
{posterImg ? (
<div className="absolute inset-0">
<img src={posterImg} alt="" className="w-full h-full object-cover scale-110 blur-xl opacity-80" />
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, rgba(15,11,8,0.45) 0%, rgba(15,11,8,0.72) 58%, rgba(15,11,8,0.9) 100%)' }} />
</div>
) : (
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, #1a1410 0%, #3a1d0c 100%)' }} />
)}
<div className="relative px-5 pt-7 pb-7 text-white flex flex-col items-center text-center">
{/* 포스터 (중앙, 크게) */}
<button
type="button"
onClick={() => posterImg && openLightbox([poster.originalUrl || poster.mediumUrl], 0)}
className="w-52 mb-5"
>
{posterImg ? (
<span className="block rounded-2xl overflow-hidden shadow-2xl ring-1 ring-white/15">
<img src={posterImg} alt={schedule.title} className="w-full h-auto object-cover" />
</span>
) : (
<span className="flex w-full aspect-[3/4] rounded-2xl bg-white/5 ring-1 ring-white/10 items-center justify-center">
<Ticket size={48} className="text-white/30" strokeWidth={1.5} />
</span>
)}
</button>
{/* 제목 */}
<h1 className="text-xl font-black leading-snug tracking-tight mb-4 px-2">
{decodeHtmlEntities(schedule.title)}
</h1>
{/* 날짜(+회차) · 장소 */}
<div className="flex flex-col items-center gap-2.5 text-white">
<div className="flex items-center gap-2">
<Calendar size={16} className="text-white/55" />
<span className="text-base font-bold">{shortDate(schedule.date)}{schedule.time && ` · ${formatTime(schedule.time)}`}</span>
{hasMultiRounds && (
<button type="button" onClick={() => setRoundsOpen(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-white/15 text-xs font-bold border border-white/20">
{currentRoundNum}회차<ChevronDown size={12} />
</button>
)}
</div>
{venue && (
<button
type="button"
onClick={() => venue.lat && venue.lng && setMapOpen(true)}
className="flex items-center gap-2 max-w-full"
>
<MapPin size={16} className="text-white/55 flex-shrink-0" />
<span className="text-base font-bold truncate">{venue.name}</span>
{venue.lat && venue.lng && <ChevronRight size={15} className="text-white/45 flex-shrink-0" />}
</button>
)}
</div>
</div>
</div>
{/* 세트리스트 */}
{setlist.length > 0 && (
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="flex items-center gap-1.5 mb-3">
<Disc3 size={16} style={{ color: ACCENT }} />
<h2 className="text-sm font-bold text-gray-900">세트리스트</h2>
<span className="text-xs text-gray-400 ml-auto">{setlist.length}</span>
</div>
<motion.div
initial={false}
animate={{ height: collapsible && !expanded ? collapsedHeight : fullHeight }}
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
className="overflow-hidden"
>
{setlist.map((song, idx) => (
<div key={song.id} className="flex items-center gap-2.5 h-[52px] px-1">
<span className="flex-shrink-0 w-7 text-right text-lg font-black tabular-nums text-gray-200 leading-none">
{String(idx + 1).padStart(2, '0')}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{song.songName}</p>
{song.albumName && <p className="text-[11px] text-gray-400 truncate mt-0.5">{song.albumName}</p>}
</div>
{isUnit(song.members) && (
<div className="flex-shrink-0 flex flex-wrap justify-end gap-1 max-w-[110px]">
{song.members.map((m) => (
<span key={m.id} className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-orange-100 text-orange-700">{m.name}</span>
))}
</div>
)}
</div>
))}
</motion.div>
{collapsible && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mt-2 w-full flex items-center justify-center gap-1 py-2.5 rounded-lg text-xs font-semibold text-gray-500 bg-gray-50 active:bg-gray-100"
>
{expanded ? '접기' : `${setlist.length - COLLAPSE_COUNT}곡 더 보기`}
<ChevronDown size={14} className={`transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
)}
</div>
)}
{/* 굿즈 (원본 비율 유지, masonry) */}
{merchandise.length > 0 && (
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="flex items-center gap-1.5 mb-3">
<ShoppingBag size={16} style={{ color: ACCENT }} />
<h2 className="text-sm font-bold text-gray-900">MD</h2>
<span className="text-xs text-gray-400">{merchandise.length}</span>
</div>
<div className="columns-2 gap-2">
{merchandise.map((md, idx) => (
<button key={md.id} type="button"
onClick={() => openLightbox(merchandise.map((x) => x.originalUrl || x.mediumUrl), idx)}
className="block w-full mb-2 break-inside-avoid rounded-lg overflow-hidden border border-gray-100">
<img src={md.mediumUrl || md.thumbUrl} alt="" className="w-full h-auto" />
</button>
))}
</div>
</div>
)}
{/* 회차 선택 바텀시트 */}
{createPortal(
<AnimatePresence>
{roundsOpen && hasMultiRounds && (
<div className="fixed inset-0 z-[60] flex items-end">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/50" onClick={() => setRoundsOpen(false)} />
<motion.div
initial={{ y: '100%' }} animate={{ y: 0 }} exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="relative w-full bg-white rounded-t-3xl p-4"
>
<div className="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-4" />
<p className="text-sm font-bold text-gray-900 mb-3 px-1">공연 일정</p>
<div className="space-y-1">
{allRounds.map((round, i) => (
round.current ? (
<div key={round.scheduleId} className="flex items-center justify-between px-3 py-3 rounded-xl font-bold"
style={{ backgroundColor: `${ACCENT}14`, color: '#9a3412' }}>
<span className="flex items-center gap-2.5">
<span className="text-xs font-bold tabular-nums w-9" style={{ color: ACCENT }}>{i + 1}회차</span>
{shortDate(round.date)}
</span>
{round.time && <span className="text-xs">{formatTime(round.time)}</span>}
</div>
) : (
<button key={round.scheduleId} type="button"
onClick={() => { setRoundsOpen(false); navigate(`/schedule/${round.scheduleId}`, { replace: true }); }}
className="w-full flex items-center justify-between px-3 py-3 rounded-xl text-gray-600 active:bg-gray-50">
<span className="flex items-center gap-2.5">
<span className="text-xs font-bold tabular-nums w-9 text-gray-400">{i + 1}회차</span>
{shortDate(round.date)}
</span>
{round.time && <span className="text-xs text-gray-400">{formatTime(round.time)}</span>}
</button>
)
))}
</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)}
{/* 장소 지도 다이얼로그 */}
{createPortal(
<AnimatePresence>
{mapOpen && venue && (
<div className="fixed inset-0 z-[60] flex items-end">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/50" onClick={() => setMapOpen(false)} />
<motion.div
initial={{ y: '100%' }} animate={{ y: 0 }} exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="relative w-full bg-white rounded-t-3xl p-4"
>
<div className="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-4" />
<div className="flex items-start gap-2 mb-3 px-1">
<MapPin size={16} style={{ color: ACCENT }} className="mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-bold text-gray-900">{venue.name}</p>
{venue.address && <p className="text-xs text-gray-500 mt-0.5">{venue.address}</p>}
</div>
</div>
<KakaoMap lat={Number(venue.lat)} lng={Number(venue.lng)} name={venue.name} className="w-full h-56 rounded-2xl overflow-hidden border border-gray-100" />
{kakaoMapUrl && (
<a href={kakaoMapUrl} target="_blank" rel="noopener noreferrer"
className="mt-3 flex items-center justify-center gap-1.5 py-3 rounded-xl text-sm font-semibold text-white"
style={{ backgroundColor: ACCENT }}>
카카오맵에서 길찾기<ExternalLink size={14} />
</a>
)}
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)}
{/* Lightbox */}
<MobileLightbox
images={lightbox.images}
currentIndex={lightbox.index}
isOpen={lightbox.open}
onClose={() => setLightbox((prev) => ({ ...prev, open: false }))}
onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))}
showCounter={lightbox.images.length > 1}
showDownload
/>
</div>
);
}
/**
* Mobile 기본 섹션
*/
@ -967,6 +1237,8 @@ function MobileScheduleDetail() {
return <MobileVarietySection schedule={schedule} />;
case '행사':
return <MobileEventSection schedule={schedule} />;
case '콘서트':
return <MobileConcertSection schedule={schedule} />;
default:
return <MobileDefaultSection schedule={schedule} />;
}

View file

@ -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 <VarietySection schedule={schedule} />;
case '행사':
return <EventSection schedule={schedule} />;
case '콘서트':
return <ConcertSection schedule={schedule} />;
default:
return <DefaultSection schedule={schedule} />;
}
@ -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 (
<div className="min-h-[calc(100vh-64px)] bg-gray-50">
<div className={`${isYoutube || isEvent ? 'max-w-5xl' : 'max-w-3xl'} mx-auto px-6 py-8`}>
<div className={`${isYoutube || isEvent || isConcert ? 'max-w-5xl' : 'max-w-3xl'} mx-auto px-6 py-8`}>
{/* 브레드크럼 네비게이션 */}
<motion.div
initial={{ opacity: 0 }}

View file

@ -0,0 +1,326 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Calendar, Clock, MapPin, ShoppingBag,
ExternalLink, Ticket, Disc3, ChevronDown, ChevronRight, X,
} from 'lucide-react';
import { Lightbox, KakaoMap } from '@/components/common';
import { decodeHtmlEntities, formatFullDate, formatTime } from './utils';
const ACCENT = '#f97316';
const COLLAPSE_COUNT = 12;
const DAYS = ['일', '월', '화', '수', '목', '금', '토'];
function shortDate(dateStr) {
const d = new Date(dateStr);
return `${d.getMonth() + 1}.${d.getDate()} (${DAYS[d.getDay()]})`;
}
/**
* 콘서트 일정 섹션 몰입형 포스터 히어로 디자인
*/
function ConcertSection({ schedule }) {
const navigate = useNavigate();
const members = schedule.members || [];
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 kakaoMapUrl = venue && venue.lat && venue.lng
? `https://map.kakao.com/link/map/${encodeURIComponent(venue.name)},${venue.lat},${venue.lng}`
: null;
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 });
const [mapOpen, setMapOpen] = useState(false);
const [roundsOpen, setRoundsOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const openLightbox = (images, index) => 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 (
<div className="space-y-6">
{/* ===== 히어로 ===== */}
<div className="relative rounded-[28px] overflow-hidden shadow-xl">
{posterImg ? (
<div className="absolute inset-0">
<img src={posterImg} alt="" className="w-full h-full object-cover scale-110 blur-xl opacity-90" />
<div className="absolute inset-0" style={{
background: 'linear-gradient(110deg, rgba(12,9,7,0.82) 0%, rgba(12,9,7,0.55) 48%, rgba(124,45,18,0.4) 100%)',
}} />
</div>
) : (
<div className="absolute inset-0" style={{ background: 'linear-gradient(120deg, #1a1410 0%, #3a1d0c 100%)' }} />
)}
<div className="relative flex gap-8 p-8 md:p-10">
{/* 포스터 */}
<div className="flex-shrink-0 w-[300px]">
{posterImg ? (
<button
type="button"
onClick={() => openLightbox([poster.originalUrl || poster.mediumUrl], 0)}
className="block w-full rounded-2xl overflow-hidden shadow-2xl ring-1 ring-white/15 cursor-pointer transition-transform duration-300 hover:scale-[1.02]"
>
<img src={posterImg} alt={`${schedule.title} 포스터`} className="w-full h-auto object-cover" />
</button>
) : (
<div className="w-full aspect-[3/4] rounded-2xl bg-white/5 ring-1 ring-white/10 flex items-center justify-center">
<Ticket size={64} className="text-white/30" strokeWidth={1.5} />
</div>
)}
</div>
{/* 정보 */}
<div className="flex-1 min-w-0 flex flex-col justify-center py-2 text-white">
<div className="flex items-center gap-2 mb-6">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold tracking-wider uppercase"
style={{ backgroundColor: ACCENT, color: '#1a0f06' }}>
<Ticket size={12} />
Concert
</span>
<span className="h-px flex-1 bg-white/15" />
</div>
<h1 className="text-3xl md:text-[40px] font-black leading-[1.08] tracking-tight mb-8">
{decodeHtmlEntities(schedule.title)}
</h1>
{/* 날짜/시간/장소 (강조) */}
<div className="space-y-4">
{/* 날짜 + 회차 드롭다운 */}
<div className="relative w-fit">
<button
type="button"
onClick={() => hasMultiRounds && setRoundsOpen((v) => !v)}
className={`flex items-center gap-2.5 text-white ${hasMultiRounds ? 'cursor-pointer group' : 'cursor-default'}`}
>
<Calendar size={20} className="text-white/55" />
<span className="text-xl font-bold">{formatFullDate(schedule.date)}</span>
{schedule.time && (
<>
<span className="text-white/30">·</span>
<span className="text-xl font-bold">{formatTime(schedule.time)}</span>
</>
)}
{hasMultiRounds && (
<span className="ml-1.5 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white/15 text-sm font-bold border border-white/20 group-hover:bg-white/25 transition-colors">
{currentRoundNum}회차
<ChevronDown size={15} className={`transition-transform ${roundsOpen ? 'rotate-180' : ''}`} />
</span>
)}
</button>
<AnimatePresence>
{roundsOpen && hasMultiRounds && (
<>
<div className="fixed inset-0 z-20" onClick={() => setRoundsOpen(false)} />
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
className="absolute top-full left-0 mt-2 z-30 w-64 bg-white rounded-2xl shadow-2xl border border-gray-100 p-2"
>
{allRounds.map((round, i) => (
<button
key={round.scheduleId}
type="button"
onClick={() => {
setRoundsOpen(false);
if (!round.current) navigate(`/schedule/${round.scheduleId}`, { replace: true });
}}
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-colors ${
round.current ? 'font-bold' : 'text-gray-600 hover:bg-gray-50'
}`}
style={round.current ? { backgroundColor: `${ACCENT}14`, color: '#9a3412' } : undefined}
>
<span className="flex items-center gap-2">
<span className="text-xs font-bold tabular-nums w-9" style={{ color: round.current ? ACCENT : '#9ca3af' }}>{i + 1}회차</span>
{shortDate(round.date)}
</span>
{round.time && <span className="text-xs text-gray-400">{formatTime(round.time)}</span>}
</button>
))}
</motion.div>
</>
)}
</AnimatePresence>
</div>
{/* 장소 (클릭 → 지도 다이얼로그) */}
{venue && (
<button
type="button"
onClick={() => venue.lat && venue.lng && setMapOpen(true)}
className={`flex items-center gap-2.5 text-white ${venue.lat && venue.lng ? 'group cursor-pointer' : 'cursor-default'}`}
>
<MapPin size={20} className="text-white/55" />
<span className="text-xl font-bold group-hover:underline underline-offset-4 decoration-white/40">{venue.name}</span>
{venue.lat && venue.lng && (
<ChevronRight size={18} className="text-white/40 group-hover:text-white/70 transition-colors" />
)}
</button>
)}
</div>
</div>
</div>
</div>
{/* ===== 세트리스트 (2단) ===== */}
{setlist.length > 0 && (
<div className="bg-white rounded-3xl shadow-sm border border-gray-100 overflow-hidden">
<div className="flex items-center gap-3 px-8 pt-7 pb-5">
<div className="w-10 h-10 rounded-full flex items-center justify-center" style={{ backgroundColor: `${ACCENT}1a` }}>
<Disc3 size={20} style={{ color: ACCENT }} />
</div>
<div>
<p className="text-xs font-bold tracking-[0.18em] uppercase" style={{ color: ACCENT }}>Setlist</p>
<h2 className="text-xl font-bold text-gray-900 -mt-0.5">세트리스트</h2>
</div>
<span className="ml-auto text-sm text-gray-400">{setlist.length}</span>
</div>
<div className="px-6 pb-2">
<motion.div
initial={false}
animate={{ height: collapsible && !expanded ? collapsedHeight : fullHeight }}
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
className="overflow-hidden"
>
<div className="grid grid-cols-2 gap-x-6 auto-rows-[60px]">
{setlist.map((song, idx) => (
<div key={song.id} className="flex items-center gap-3 px-2 rounded-xl hover:bg-gray-50 transition-colors">
<span className="flex-shrink-0 w-8 text-right text-xl font-black tabular-nums leading-none text-gray-200">
{String(idx + 1).padStart(2, '0')}
</span>
<div className="flex-1 min-w-0">
<p className="text-[15px] font-semibold text-gray-900 truncate">{song.songName}</p>
{song.albumName && <p className="text-xs text-gray-400 truncate mt-0.5">{song.albumName}</p>}
</div>
{isUnit(song.members) && (
<div className="flex-shrink-0 flex flex-wrap justify-end gap-1 max-w-[140px]">
{song.members.map((m) => (
<span key={m.id} className="px-2 py-0.5 rounded-full text-xs font-semibold bg-orange-100 text-orange-700">
{m.name}
</span>
))}
</div>
)}
</div>
))}
</div>
</motion.div>
</div>
{/* 펼치기/접기 */}
{collapsible && (
<div className="px-6 pb-6 pt-2">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-center gap-1.5 py-3 rounded-xl text-sm font-semibold text-gray-500 bg-gray-50 hover:bg-gray-100 transition-colors"
>
{expanded ? '접기' : `${setlist.length - COLLAPSE_COUNT}곡 더 보기`}
<ChevronDown size={16} className={`transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
</div>
)}
</div>
)}
{/* ===== 굿즈 (MD) — 원본 비율 유지, masonry ===== */}
{merchandise.length > 0 && (
<div className="bg-white rounded-3xl shadow-sm border border-gray-100 p-7">
<div className="flex items-center gap-2.5 mb-5">
<ShoppingBag size={18} style={{ color: ACCENT }} />
<h2 className="text-base font-bold text-gray-900">MD</h2>
<span className="text-sm text-gray-400">{merchandise.length}</span>
</div>
<div className="columns-3 gap-3">
{merchandise.map((md, idx) => (
<button key={md.id} type="button"
onClick={() => openLightbox(merchandise.map((x) => x.originalUrl || x.mediumUrl), idx)}
className="block w-full mb-3 break-inside-avoid rounded-xl overflow-hidden border border-gray-100 cursor-pointer hover:opacity-95 transition-opacity">
<img src={md.mediumUrl || md.thumbUrl} alt="" className="w-full h-auto" />
</button>
))}
</div>
</div>
)}
{/* ===== 장소 지도 다이얼로그 ===== */}
<AnimatePresence>
{mapOpen && venue && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/50" onClick={() => setMapOpen(false)} />
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 10 }}
transition={{ duration: 0.18 }}
className="relative bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden"
>
<div className="flex items-start justify-between p-5 pb-4">
<div className="flex items-start gap-2.5">
<MapPin size={20} style={{ color: ACCENT }} className="mt-0.5 flex-shrink-0" />
<div>
<p className="font-bold text-gray-900 text-lg">{venue.name}</p>
{venue.address && <p className="text-sm text-gray-500 mt-0.5">{venue.address}</p>}
</div>
</div>
<button onClick={() => setMapOpen(false)} className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors">
<X size={18} className="text-gray-400" />
</button>
</div>
<div className="px-5 pb-5">
<KakaoMap lat={Number(venue.lat)} lng={Number(venue.lng)} name={venue.name}
className="w-full h-64 rounded-2xl overflow-hidden border border-gray-100" />
{kakaoMapUrl && (
<a href={kakaoMapUrl} target="_blank" rel="noopener noreferrer"
className="mt-3 flex items-center justify-center gap-1.5 py-2.5 rounded-xl text-sm font-semibold text-white transition-opacity hover:opacity-90"
style={{ backgroundColor: ACCENT }}>
카카오맵에서 길찾기
<ExternalLink size={14} />
</a>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Lightbox */}
<Lightbox
images={lightbox.images}
currentIndex={lightbox.index}
isOpen={lightbox.open}
onClose={() => setLightbox((prev) => ({ ...prev, open: false }))}
onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))}
showCounter={lightbox.images.length > 1}
showDownload
/>
</div>
);
}
export default ConcertSection;

View file

@ -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';