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, ChevronDown, Tv, ExternalLink, Play, MapPin, PartyPopper, ShoppingBag, Ticket, Disc3 } from 'lucide-react';
import { getSchedule } from '@/api';
import { KakaoMap, MobileLightbox } from '@/components/common';
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
import Birthday from './Birthday';
/**
* URL을 링크로 변환하는 함수
*/
function linkifyText(text) {
if (!text) return null;
// 해시태그 또는 URL 매칭
const pattern = /(#[^\s#]+)|(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
const parts = [];
let lastIndex = 0;
let match;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const matched = match[0];
if (matched.startsWith('#')) {
// 해시태그
const tag = matched.slice(1);
parts.push(
{matched}
);
} else {
// URL
const href = matched.startsWith('http') ? matched : `https://${matched}`;
parts.push(
{matched}
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : text;
}
/**
* 특수 일정 ID 파싱
* @param {string} id - 일정 ID
* @returns {object|null} { type, year, nameEn } 또는 null
*/
function parseSpecialId(id) {
// birthday-{year}-{nameEn} 형식
const birthdayMatch = id.match(/^birthday-(\d{4})-(.+)$/);
if (birthdayMatch) {
return { type: 'birthday', year: birthdayMatch[1], nameEn: birthdayMatch[2] };
}
// debut-{year} 형식
const debutMatch = id.match(/^debut-(\d{4})$/);
if (debutMatch) {
return { type: 'debut', year: debutMatch[1] };
}
// anniversary-{year} 형식
const anniversaryMatch = id.match(/^anniversary-(\d{4})$/);
if (anniversaryMatch) {
return { type: 'anniversary', year: anniversaryMatch[1] };
}
return null;
}
/**
* 전체화면 시 자동 가로 회전 훅 (숏츠가 아닐 때만)
*/
function useFullscreenOrientation(isShorts) {
useEffect(() => {
if (isShorts) return;
const handleFullscreenChange = async () => {
const isFullscreen = !!document.fullscreenElement;
if (isFullscreen) {
try {
if (screen.orientation && screen.orientation.lock) {
await screen.orientation.lock('landscape');
}
} catch (e) {
// 지원하지 않는 브라우저
}
} else {
try {
if (screen.orientation && screen.orientation.unlock) {
screen.orientation.unlock();
}
} catch (e) {
// 무시
}
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
};
}, [isShorts]);
}
/**
* Mobile 예정 일정 Placeholder 컴포넌트
*/
function MobileScheduledPlaceholder({ bannerUrl }) {
return (
{/* 배경: 배너 이미지 또는 패턴 */}
{bannerUrl ? (
) : (
)}
{/* 하단 텍스트 */}
);
}
/**
* Mobile 유튜브 섹션
*/
function MobileYoutubeSection({ schedule }) {
const videoId = schedule.videoId;
const isShorts = schedule.videoType === 'shorts';
const isScheduled = !videoId; // videoId가 없으면 예정 일정
// 숏츠가 아닐 때만 가로 회전 (숏츠는 전체화면에서 세로 유지)
useFullscreenOrientation(isShorts);
const members = schedule.members || [];
const isFullGroup = members.length === 5;
return (
{/* 영상 임베드 또는 예정 Placeholder */}
{isScheduled ? (
) : (
)}
{/* 영상 정보 */}
{decodeHtmlEntities(schedule.title)}
{isScheduled && (
예정
)}
{/* 메타 정보 */}
0 || !isScheduled ? 'mb-3' : ''}`}>
{formatXDateTimeWithTime(schedule.date, schedule.time)}
{schedule.channelName && (
{schedule.channelName}
)}
{/* 멤버 목록 */}
{members.length > 0 && (
{isFullGroup ? (
프로미스나인
) : (
members.map((member) => (
{member.name}
))
)}
)}
{/* 유튜브에서 보기 버튼 (예정 일정이 아닐 때만) */}
{!isScheduled && (
)}
);
}
/**
* 카드 이미지 (로드 실패 시 fallback 아이콘 — 인스타 등 CDN 만료 대비)
*/
function CardImage({ src, className }) {
const [error, setError] = useState(false);
if (!src || error) {
return (
);
}
return
setError(true)} />;
}
/**
* Mobile X(트위터) 섹션
*/
function MobileXSection({ schedule }) {
const profile = schedule.profile;
const username = profile?.username || 'realfromis_9';
const displayName = profile?.displayName || username;
const avatarUrl = profile?.avatarUrl;
// 라이트박스 상태
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const historyPushedRef = useRef(false);
const openLightbox = (index) => {
setLightboxIndex(index);
setLightboxOpen(true);
window.history.pushState({ lightbox: true }, '');
historyPushedRef.current = true;
};
const closeLightbox = () => {
setLightboxOpen(false);
if (historyPushedRef.current) {
historyPushedRef.current = false;
window.history.back();
}
};
const goToPrev = () => {
if (schedule.imageUrls?.length > 1) {
setLightboxIndex((lightboxIndex - 1 + schedule.imageUrls.length) % schedule.imageUrls.length);
}
};
const goToNext = () => {
if (schedule.imageUrls?.length > 1) {
setLightboxIndex((lightboxIndex + 1) % schedule.imageUrls.length);
}
};
// 라이트박스 열릴 때 body 스크롤 방지
useEffect(() => {
if (lightboxOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [lightboxOpen]);
// 뒤로가기 처리 (하드웨어 백버튼)
useEffect(() => {
const handlePopState = () => {
if (lightboxOpen) {
historyPushedRef.current = false;
setLightboxOpen(false);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [lightboxOpen]);
return (
<>
{/* 헤더 */}
{avatarUrl ? (

) : (
{displayName.charAt(0).toUpperCase()}
)}
{/* 본문 */}
{linkifyText(decodeHtmlEntities(schedule.content || schedule.title))}
{/* 이미지 */}
{schedule.imageUrls?.length > 0 && (
{schedule.imageUrls.length === 1 ? (

openLightbox(0)}
/>
) : (
{schedule.imageUrls.slice(0, 4).map((url, i) => (

openLightbox(i)}
/>
))}
)}
)}
{/* 영상 썸네일 (재생 버튼 → 원본 트윗으로 이동) */}
{schedule.videoThumbnails?.length > 0 && (
)}
{/* 링크 미리보기 카드 (Open Graph) — 자체 이미지/영상이 있으면 숨김 */}
{schedule.card?.url && !(schedule.videoThumbnails?.length > 0) && !(schedule.imageUrls?.length > 0) && (
)}
{/* 날짜/시간 */}
{formatXDateTimeWithTime(schedule.date, schedule.time)}
{/* X에서 보기 버튼 */}
{/* 모바일 라이트박스 */}
{lightboxOpen && schedule.imageUrls?.length > 0 && (
e.stopPropagation()}
/>
{schedule.imageUrls.length > 1 && (
<>
>
)}
{schedule.imageUrls.length > 1 && (
{schedule.imageUrls.map((_, i) => (
)}
)}
>
);
}
/**
* Mobile 행사 섹션 (학교 행사 등)
*/
function MobileEventSection({ schedule }) {
const members = schedule.members || [];
const isFullGroup = members.length === 5;
const posters = schedule.posters || [];
const postUrls = schedule.postUrls || [];
const venue = schedule.venue || null;
const categoryColor = schedule.category?.color || '#facc15';
const kakaoMapUrl = venue && venue.lat && venue.lng
? `https://map.kakao.com/link/map/${encodeURIComponent(venue.name)},${venue.lat},${venue.lng}`
: null;
return (
{/* 포스터 */}
{posters.length > 0 ? (
{posters.length > 1 && (
{posters.slice(1).map((p) => (
))}
)}
) : (
)}
{/* 정보 카드 */}
{schedule.subtype === 'university' ? '대학 축제' : (schedule.category?.name || '행사')}
{formatFullDate(schedule.date)}
{schedule.time && ` · ${formatTime(schedule.time)}`}
{decodeHtmlEntities(schedule.title)}
{members.length > 0 && (
{isFullGroup ? (
프로미스나인
) : (
members.map((member) => (
{member.name}
))
)}
)}
{venue && (
{venue.name}
{venue.address && (
{venue.address}
)}
{kakaoMapUrl && (
카카오맵에서 보기
)}
{venue.lat && venue.lng && (
)}
)}
{postUrls.length > 0 && (
관련 링크
{postUrls.map((url, idx) => (
-
·
{url}
))}
)}
);
}
/**
* Mobile 예능 섹션
*/
function MobileVarietySection({ schedule }) {
const members = schedule.members || [];
const isFullGroup = members.length === 5;
const hasThumbnail = !!schedule.thumbnailUrl;
const hasReplayUrl = !!schedule.replayUrl;
const isYoutubeReplay = hasReplayUrl && /youtu\.?be/i.test(schedule.replayUrl);
const categoryColor = schedule.category?.color || '#06b6d4';
return (
{/* 썸네일 카드 */}
{hasThumbnail ? (
<>
{/* 블러 배경 */}

{/* 메인 이미지 */}

>
) : (
)}
{/* 정보 카드 */}
{/* 방송사 + 날짜 */}
{schedule.broadcaster && (
{schedule.broadcaster}
)}
{formatFullDate(schedule.date)}
{schedule.time && ` · ${formatTime(schedule.time)}`}
{/* 제목 */}
{decodeHtmlEntities(schedule.title)}
{/* 멤버 */}
{members.length > 0 && (
{isFullGroup ? (
프로미스나인
) : (
members.map((member) => (
{member.name}
))
)}
)}
{/* 다시보기 */}
{hasReplayUrl && (
)}
);
}
/**
* 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 기본 섹션
*/
function MobileDefaultSection({ schedule }) {
return (
{decodeHtmlEntities(schedule.title)}
{formatFullDate(schedule.date)}
{schedule.time && (
{formatTime(schedule.time)}
)}
{schedule.description && (
{decodeHtmlEntities(schedule.description)}
)}
);
}
/**
* Mobile 일정 상세 페이지
*/
function MobileScheduleDetail() {
const { id } = useParams();
// 특수 일정 ID 체크
const specialId = parseSpecialId(id);
if (specialId?.type === 'birthday') {
return ;
}
// 모바일 레이아웃 활성화
useEffect(() => {
document.documentElement.classList.add('mobile-layout');
return () => {
document.documentElement.classList.remove('mobile-layout');
};
}, []);
const {
data: schedule,
isLoading,
error,
} = useQuery({
queryKey: ['schedule', id],
queryFn: () => getSchedule(id),
placeholderData: keepPreviousData,
retry: false,
});
if (isLoading && !schedule) {
return (
);
}
if (error || !schedule) {
return (
일정을 찾을 수 없습니다
요청하신 일정이 존재하지 않거나
삭제되었을 수 있습니다.
{[...Array(5)].map((_, i) => (
))}
일정 목록
);
}
// 카테고리별 섹션 렌더링
const categoryName = schedule.category?.name;
const renderCategorySection = () => {
switch (categoryName) {
case '유튜브':
return ;
case 'X':
return ;
case '예능':
return ;
case '행사':
return ;
case '콘서트':
return ;
default:
return ;
}
};
return (
{/* 헤더 */}
{schedule.category?.name}
{/* 메인 컨텐츠 */}
{renderCategorySection()}
);
}
export default MobileScheduleDetail;