import { useState, useEffect, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react'; import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; import { useInView } from 'react-intersection-observer'; import { useVirtualizer } from '@tanstack/react-virtual'; import { getTodayKST, getCategoryInfo } from '@/utils'; import { getSchedules, searchSchedules } from '@/api'; import { useScheduleStore } from '@/stores'; import { MIN_YEAR, SEARCH_LIMIT } from '@/constants'; import { Calendar as MobileCalendar, ScheduleListCard as MobileScheduleListCard, ScheduleSearchCard as MobileScheduleSearchCard, BirthdayCard as MobileBirthdayCard, } from '@/components/mobile'; import { fireBirthdayConfetti } from '@/utils'; /** * 모바일 일정 페이지 */ function MobileSchedule() { const navigate = useNavigate(); // zustand store에서 상태 가져오기 const { selectedDate: storedSelectedDate, setSelectedDate: setStoredSelectedDate, } = useScheduleStore(); // 선택된 날짜 (store에 없으면 오늘 날짜) const selectedDate = storedSelectedDate || new Date(); const setSelectedDate = (date) => setStoredSelectedDate(date); const [isSearchMode, setIsSearchMode] = useState(false); const [searchInput, setSearchInput] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [showCalendar, setShowCalendar] = useState(false); const [calendarViewDate, setCalendarViewDate] = useState(() => new Date(selectedDate)); const [calendarShowYearMonth, setCalendarShowYearMonth] = useState(false); const contentRef = useRef(null); const searchContainerRef = useRef(null); const searchInputRef = useRef(null); // 검색 추천 관련 상태 const [showSuggestions, setShowSuggestions] = useState(false); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [originalSearchQuery, setOriginalSearchQuery] = useState(''); const [suggestions, setSuggestions] = useState([]); const [lastSearchTerm, setLastSearchTerm] = useState(''); const [showSuggestionsScreen, setShowSuggestionsScreen] = useState(false); // 검색 모드 진입/종료 const enterSearchMode = () => { setIsSearchMode(true); window.history.pushState({ searchMode: true }, ''); }; const exitSearchMode = () => { setIsSearchMode(false); setSearchInput(''); setOriginalSearchQuery(''); setSearchTerm(''); setLastSearchTerm(''); setShowSuggestions(false); setShowSuggestionsScreen(false); setSelectedSuggestionIndex(-1); }; const hideSuggestionsScreen = () => { setShowSuggestionsScreen(false); setSearchInput(lastSearchTerm); setOriginalSearchQuery(lastSearchTerm); }; // 뒤로가기 버튼 처리 useEffect(() => { const handlePopState = () => { if (isSearchMode) { if (showSuggestionsScreen && searchTerm) { hideSuggestionsScreen(); window.history.pushState({ searchMode: true }, ''); } else { exitSearchMode(); } } }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, [isSearchMode, showSuggestionsScreen, searchTerm, lastSearchTerm]); // 달력 월 변경 const changeCalendarMonth = (delta) => { const newDate = new Date(calendarViewDate); newDate.setMonth(newDate.getMonth() + delta); setCalendarViewDate(newDate); }; const scrollContainerRef = useRef(null); const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' }); // 검색 무한 스크롤 const { data: searchData, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading: searchLoading, } = useInfiniteQuery({ queryKey: ['mobileScheduleSearch', searchTerm], queryFn: async ({ pageParam = 0 }) => { return searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT }); }, getNextPageParam: (lastPage) => { if (lastPage.hasMore) { return lastPage.offset + lastPage.schedules.length; } return undefined; }, enabled: !!searchTerm && isSearchMode, }); const searchResults = useMemo(() => { if (!searchData?.pages) return []; return searchData.pages.flatMap((page) => page.schedules); }, [searchData]); // 가상 스크롤 설정 const virtualizer = useVirtualizer({ count: isSearchMode && searchTerm ? searchResults.length : 0, getScrollElement: () => scrollContainerRef.current, estimateSize: () => 100, overscan: 5, }); // 검색어 변경 시 스크롤 위치 초기화 useEffect(() => { if (searchTerm && !showSuggestionsScreen) { requestAnimationFrame(() => { virtualizer.scrollToOffset(0); if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0; } }); } }, [searchTerm, showSuggestionsScreen]); useEffect(() => { if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { fetchNextPage(); } }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); // 일정 데이터 로드 const viewYear = selectedDate.getFullYear(); const viewMonth = selectedDate.getMonth() + 1; const { data: schedules = [], isLoading: loading } = useQuery({ queryKey: ['schedules', viewYear, viewMonth], queryFn: () => getSchedules(viewYear, viewMonth), }); // 달력 표시용 일정 데이터 const calendarYear = calendarViewDate.getFullYear(); const calendarMonth = calendarViewDate.getMonth() + 1; const isSameMonth = viewYear === calendarYear && viewMonth === calendarMonth; const { data: calendarSchedules = [] } = useQuery({ queryKey: ['schedules', calendarYear, calendarMonth], queryFn: () => getSchedules(calendarYear, calendarMonth), enabled: !isSameMonth, }); // 생일 폭죽 효과 useEffect(() => { if (loading || schedules.length === 0) return; const today = getTodayKST(); const confettiKey = `birthday-confetti-${today}`; if (localStorage.getItem(confettiKey)) return; const hasBirthdayToday = schedules.some((s) => { if (!s.is_birthday) return false; const scheduleDate = s.date ? s.date.split('T')[0] : ''; return scheduleDate === today; }); if (hasBirthdayToday) { const timer = setTimeout(() => { fireBirthdayConfetti(); localStorage.setItem(confettiKey, 'true'); }, 500); return () => clearTimeout(timer); } }, [schedules, loading]); // 2017년 1월 이전으로 이동 불가 const canGoPrevMonth = !(selectedDate.getFullYear() === MIN_YEAR && selectedDate.getMonth() === 0); // 월 변경 const changeMonth = (delta) => { if (delta < 0 && !canGoPrevMonth) return; const newDate = new Date(selectedDate); newDate.setMonth(newDate.getMonth() + delta); const today = new Date(); if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) { newDate.setDate(today.getDate()); } else { newDate.setDate(1); } setSelectedDate(newDate); setCalendarViewDate(newDate); }; // 날짜 변경 시 스크롤 초기화 useEffect(() => { if (contentRef.current) { contentRef.current.scrollTop = 0; } }, [selectedDate]); // 캘린더 열릴 때 배경 스크롤 방지 useEffect(() => { const preventScroll = (e) => e.preventDefault(); if (showCalendar) { document.addEventListener('touchmove', preventScroll, { passive: false }); } else { document.removeEventListener('touchmove', preventScroll); } return () => { document.removeEventListener('touchmove', preventScroll); }; }, [showCalendar]); // 검색 추천 드롭다운 외부 클릭 감지 useEffect(() => { const handleClickOutside = (event) => { if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) { setShowSuggestions(false); setSelectedSuggestionIndex(-1); } }; if (showSuggestions) { document.addEventListener('mousedown', handleClickOutside); document.addEventListener('touchstart', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('touchstart', handleClickOutside); }; }, [showSuggestions]); // 검색어 자동완성 API 호출 useEffect(() => { if (!originalSearchQuery || originalSearchQuery.trim().length === 0) { setSuggestions([]); return; } const timeoutId = setTimeout(async () => { try { const response = await fetch( `/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10` ); if (response.ok) { const data = await response.json(); setSuggestions(data.suggestions || []); } } catch (error) { console.error('추천 검색어 API 오류:', error); setSuggestions([]); } }, 200); return () => clearTimeout(timeoutId); }, [originalSearchQuery]); // 해당 달의 모든 날짜 배열 const daysInMonth = useMemo(() => { const year = selectedDate.getFullYear(); const month = selectedDate.getMonth(); const lastDay = new Date(year, month + 1, 0).getDate(); const days = []; for (let d = 1; d <= lastDay; d++) { days.push(new Date(year, month, d)); } return days; }, [selectedDate]); // 선택된 날짜의 일정 (생일 우선) const selectedDateSchedules = useMemo(() => { const year = selectedDate.getFullYear(); const month = String(selectedDate.getMonth() + 1).padStart(2, '0'); const day = String(selectedDate.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; return schedules .filter((s) => s.date.split('T')[0] === dateStr) .sort((a, b) => { const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-'); const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-'); if (aIsBirthday && !bIsBirthday) return -1; if (!aIsBirthday && bIsBirthday) return 1; return 0; }); }, [schedules, selectedDate]); // 요일 이름 const getDayName = (date) => ['일', '월', '화', '수', '목', '금', '토'][date.getDay()]; // 오늘 여부 const isToday = (date) => { const today = new Date(); return ( date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear() ); }; // 선택된 날짜 여부 const isSelected = (date) => { return ( date.getDate() === selectedDate.getDate() && date.getMonth() === selectedDate.getMonth() && date.getFullYear() === selectedDate.getFullYear() ); }; // 날짜 선택 컨테이너 ref const dateScrollRef = useRef(null); // 선택된 날짜로 자동 스크롤 useEffect(() => { if (!isSearchMode && dateScrollRef.current) { const selectedDay = selectedDate.getDate(); const buttons = dateScrollRef.current.querySelectorAll('button'); if (buttons[selectedDay - 1]) { setTimeout(() => { buttons[selectedDay - 1].scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest', }); }, 50); } } }, [selectedDate, isSearchMode]); // 검색 실행 핸들러 const handleSearch = (term) => { if (term) { setSearchInput(term); setSearchTerm(term); setLastSearchTerm(term); setShowSuggestionsScreen(false); } setShowSuggestions(false); setSelectedSuggestionIndex(-1); searchInputRef.current?.blur(); }; return ( <> {/* 툴바 (헤더 + 날짜 선택기) */}
{isSearchMode ? (
{ setSearchInput(e.target.value); setOriginalSearchQuery(e.target.value); setShowSuggestions(true); setShowSuggestionsScreen(true); setSelectedSuggestionIndex(-1); }} onFocus={() => { setShowSuggestions(true); setShowSuggestionsScreen(true); }} onKeyDown={(e) => { if (e.key === 'ArrowDown') { e.preventDefault(); const newIndex = selectedSuggestionIndex < suggestions.length - 1 ? selectedSuggestionIndex + 1 : 0; setSelectedSuggestionIndex(newIndex); if (suggestions[newIndex]) { setSearchInput(suggestions[newIndex]); } } else if (e.key === 'ArrowUp') { e.preventDefault(); const newIndex = selectedSuggestionIndex > 0 ? selectedSuggestionIndex - 1 : suggestions.length - 1; setSelectedSuggestionIndex(newIndex); if (suggestions[newIndex]) { setSearchInput(suggestions[newIndex]); } } else if (e.key === 'Enter') { e.preventDefault(); const term = selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex] ? suggestions[selectedSuggestionIndex] : searchInput.trim(); handleSearch(term); } }} className="flex-1 bg-transparent outline-none text-sm min-w-0 [&::-webkit-search-cancel-button]:hidden" autoFocus={!searchTerm} /> {searchInput && ( )}
) : (
{showCalendar ? ( <>
) : ( <>
{selectedDate.getFullYear()}년 {selectedDate.getMonth() + 1}월
)}
)} {/* 가로 스크롤 날짜 선택기 */} {!isSearchMode && (
{daysInMonth.map((date) => { const dayOfWeek = date.getDay(); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; const daySchedules = schedules .filter((s) => s.date?.split('T')[0] === dateStr) .slice(0, 3); return ( ); })}
)}
{/* 달력 팝업 */} {showCalendar && !isSearchMode && ( { setSelectedDate(date); setCalendarViewDate(date); setCalendarShowYearMonth(false); setShowCalendar(false); }} /> )} {/* 캘린더 배경 오버레이 */} {showCalendar && !isSearchMode && ( setShowCalendar(false)} className="fixed inset-0 bg-black/40 z-40" style={{ top: 0 }} /> )} {/* 컨텐츠 영역 */}
{isSearchMode ? ( showSuggestionsScreen ? ( // 추천 검색어 화면
{suggestions.length === 0 ? (
검색어를 입력하세요
) : ( suggestions.map((suggestion, index) => ( )) )}
) : !searchTerm ? (
검색어를 입력하세요
) : searchLoading ? (
) : searchResults.length === 0 ? (
검색 결과가 없습니다
) : ( <>
{virtualizer.getVirtualItems().map((virtualItem) => { const schedule = searchResults[virtualItem.index]; if (!schedule) return null; return (
navigate(`/schedule/${schedule.id}`)} />
); })}
{isFetchingNextPage && (
)}
) ) : loading ? (
) : selectedDateSchedules.length === 0 ? (
{selectedDate.getMonth() + 1}월 {selectedDate.getDate()}일 일정이 없습니다
) : (
{selectedDateSchedules.map((schedule, index) => { const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-'); if (isBirthday) { return ( { const scheduleYear = new Date(schedule.date).getFullYear(); const memberName = schedule.member_names; navigate(`/birthday/${encodeURIComponent(memberName)}/${scheduleYear}`); }} /> ); } return ( navigate(`/schedule/${schedule.id}`)} /> ); })}
)}
); } export default MobileSchedule;