2026-01-22 11:32:43 +09:00
|
|
|
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';
|
2026-01-22 18:37:30 +09:00
|
|
|
import { getSchedules, searchSchedules } from '@/api';
|
2026-01-22 11:32:43 +09:00
|
|
|
import { useScheduleStore } from '@/stores';
|
|
|
|
|
import { MIN_YEAR, SEARCH_LIMIT } from '@/constants';
|
|
|
|
|
import {
|
2026-01-22 12:08:47 +09:00
|
|
|
Calendar as MobileCalendar,
|
|
|
|
|
ScheduleListCard as MobileScheduleListCard,
|
|
|
|
|
ScheduleSearchCard as MobileScheduleSearchCard,
|
|
|
|
|
BirthdayCard as MobileBirthdayCard,
|
2026-01-24 15:04:29 +09:00
|
|
|
DebutCard as MobileDebutCard,
|
2026-01-22 12:08:47 +09:00
|
|
|
} from '@/components/mobile';
|
2026-03-20 13:39:55 +09:00
|
|
|
import { DebutCelebrationDialog, BirthdayCelebrationDialog } from '@/components/common';
|
2026-01-24 15:04:29 +09:00
|
|
|
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
|
2026-01-22 11:32:43 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 모바일 일정 페이지
|
|
|
|
|
*/
|
|
|
|
|
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);
|
2026-01-24 15:04:29 +09:00
|
|
|
const [showDebutDialog, setShowDebutDialog] = useState(false);
|
|
|
|
|
const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 });
|
2026-03-20 13:39:55 +09:00
|
|
|
const [showBirthdayDialog, setShowBirthdayDialog] = useState(false);
|
|
|
|
|
const [birthdayInfo, setBirthdayInfo] = useState({ title: '', memberImage: '', date: '' });
|
2026-01-22 11:32:43 +09:00
|
|
|
|
|
|
|
|
// 검색 모드 진입/종료
|
|
|
|
|
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) {
|
2026-03-20 13:39:55 +09:00
|
|
|
const birthdaySchedule = schedules.find((s) => {
|
|
|
|
|
if (!s.is_birthday) return false;
|
|
|
|
|
const scheduleDate = s.date ? s.date.split('T')[0] : '';
|
|
|
|
|
return scheduleDate === today;
|
|
|
|
|
});
|
2026-01-22 11:32:43 +09:00
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
fireBirthdayConfetti();
|
2026-03-20 13:39:55 +09:00
|
|
|
setBirthdayInfo({
|
|
|
|
|
title: birthdaySchedule?.title || '',
|
|
|
|
|
memberImage: birthdaySchedule?.member_image || '',
|
|
|
|
|
date: birthdaySchedule?.date || '',
|
|
|
|
|
});
|
|
|
|
|
setShowBirthdayDialog(true);
|
2026-01-22 11:32:43 +09:00
|
|
|
localStorage.setItem(confettiKey, 'true');
|
|
|
|
|
}, 500);
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}
|
|
|
|
|
}, [schedules, loading]);
|
|
|
|
|
|
2026-01-24 15:04:29 +09:00
|
|
|
// 데뷔/주년 폭죽 효과 및 다이얼로그
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (loading || schedules.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const today = getTodayKST();
|
|
|
|
|
const confettiKey = `debut-confetti-${today}`;
|
|
|
|
|
|
|
|
|
|
if (localStorage.getItem(confettiKey)) return;
|
|
|
|
|
|
|
|
|
|
const debutSchedule = schedules.find((s) => {
|
|
|
|
|
if (!s.is_debut && !s.is_anniversary) return false;
|
|
|
|
|
const scheduleDate = s.date ? s.date.split('T')[0] : '';
|
|
|
|
|
return scheduleDate === today;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (debutSchedule) {
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
fireDebutConfetti();
|
|
|
|
|
setDebutDialogInfo({
|
|
|
|
|
isDebut: debutSchedule.is_debut,
|
|
|
|
|
anniversaryYear: debutSchedule.anniversary_year || 0,
|
|
|
|
|
});
|
|
|
|
|
setShowDebutDialog(true);
|
|
|
|
|
localStorage.setItem(confettiKey, 'true');
|
|
|
|
|
}, 500);
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}
|
|
|
|
|
}, [schedules, loading]);
|
|
|
|
|
|
2026-01-22 11:32:43 +09:00
|
|
|
// 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}`;
|
2026-01-24 18:30:51 +09:00
|
|
|
// 백엔드에서 이미 정렬된 상태로 전달됨 (특수 일정 우선)
|
|
|
|
|
return schedules.filter((s) => s.date.split('T')[0] === dateStr);
|
2026-01-22 11:32:43 +09:00
|
|
|
}, [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 (
|
|
|
|
|
<>
|
|
|
|
|
{/* 툴바 (헤더 + 날짜 선택기) */}
|
|
|
|
|
<div className="mobile-toolbar-schedule shadow-sm z-50">
|
|
|
|
|
{isSearchMode ? (
|
|
|
|
|
<div className="flex items-center gap-3 px-4 py-3 relative" ref={searchContainerRef}>
|
|
|
|
|
<div className="flex-1 flex items-center gap-2 bg-gray-100 rounded-full px-4 py-2 min-w-0">
|
|
|
|
|
<Search size={18} className="text-gray-400 flex-shrink-0" />
|
|
|
|
|
<input
|
|
|
|
|
ref={searchInputRef}
|
|
|
|
|
type="text"
|
|
|
|
|
inputMode="search"
|
|
|
|
|
enterKeyHint="search"
|
|
|
|
|
placeholder="일정 검색..."
|
|
|
|
|
value={searchInput}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
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 && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSearchInput('');
|
|
|
|
|
setOriginalSearchQuery('');
|
|
|
|
|
setShowSuggestions(true);
|
|
|
|
|
setShowSuggestionsScreen(true);
|
|
|
|
|
setSelectedSuggestionIndex(-1);
|
|
|
|
|
}}
|
|
|
|
|
className="flex-shrink-0"
|
|
|
|
|
>
|
|
|
|
|
<X size={18} className="text-gray-400" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<button onClick={exitSearchMode} className="text-sm text-gray-500 flex-shrink-0">
|
|
|
|
|
취소
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="relative flex items-center justify-between px-4 py-3">
|
|
|
|
|
{showCalendar ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setShowCalendar(false);
|
|
|
|
|
setCalendarShowYearMonth(false);
|
|
|
|
|
}}
|
|
|
|
|
className="p-2 rounded-lg hover:bg-gray-100"
|
|
|
|
|
>
|
|
|
|
|
<Calendar size={20} className="text-primary" />
|
|
|
|
|
</button>
|
|
|
|
|
<button onClick={() => changeCalendarMonth(-1)} className="p-2">
|
|
|
|
|
<ChevronLeft size={20} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setCalendarShowYearMonth(!calendarShowYearMonth)}
|
|
|
|
|
className={`absolute left-1/2 -translate-x-1/2 font-bold transition-colors ${
|
|
|
|
|
calendarShowYearMonth ? 'text-primary' : ''
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{calendarViewDate.getFullYear()}년 {calendarViewDate.getMonth() + 1}월
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setCalendarShowYearMonth(!calendarShowYearMonth)}
|
|
|
|
|
className={`absolute transition-colors ${
|
|
|
|
|
calendarShowYearMonth ? 'text-primary' : ''
|
|
|
|
|
}`}
|
|
|
|
|
style={{ left: 'calc(50% + 52px)' }}
|
|
|
|
|
>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
size={16}
|
|
|
|
|
className={`transition-transform duration-200 ${
|
|
|
|
|
calendarShowYearMonth ? 'rotate-180' : ''
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<button onClick={() => changeCalendarMonth(1)} className="p-2">
|
|
|
|
|
<ChevronRight size={20} />
|
|
|
|
|
</button>
|
|
|
|
|
<button onClick={enterSearchMode} className="p-2">
|
|
|
|
|
<Search size={20} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setCalendarViewDate(selectedDate);
|
|
|
|
|
setShowCalendar(true);
|
|
|
|
|
}}
|
|
|
|
|
className="p-2 rounded-lg hover:bg-gray-100"
|
|
|
|
|
>
|
|
|
|
|
<Calendar size={20} className="text-gray-600" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => changeMonth(-1)}
|
|
|
|
|
disabled={!canGoPrevMonth}
|
|
|
|
|
className={`p-2 ${!canGoPrevMonth ? 'opacity-30' : ''}`}
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft size={20} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="font-bold">
|
|
|
|
|
{selectedDate.getFullYear()}년 {selectedDate.getMonth() + 1}월
|
|
|
|
|
</span>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<button onClick={() => changeMonth(1)} className="p-2">
|
|
|
|
|
<ChevronRight size={20} />
|
|
|
|
|
</button>
|
|
|
|
|
<button onClick={enterSearchMode} className="p-2">
|
|
|
|
|
<Search size={20} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 가로 스크롤 날짜 선택기 */}
|
|
|
|
|
{!isSearchMode && (
|
|
|
|
|
<div
|
|
|
|
|
ref={dateScrollRef}
|
|
|
|
|
className="flex overflow-x-auto scrollbar-hide px-2 py-2 gap-1"
|
|
|
|
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
|
|
|
>
|
|
|
|
|
{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 (
|
|
|
|
|
<button
|
|
|
|
|
key={date.getDate()}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSelectedDate(date);
|
|
|
|
|
setCalendarViewDate(date);
|
|
|
|
|
}}
|
|
|
|
|
className={`flex flex-col items-center min-w-[44px] h-[64px] py-2 px-1 rounded-xl transition-all ${
|
|
|
|
|
isSelected(date) ? 'bg-primary text-white' : 'hover:bg-gray-100'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
className={`text-[10px] font-medium ${
|
|
|
|
|
isSelected(date)
|
|
|
|
|
? 'text-white/80'
|
|
|
|
|
: dayOfWeek === 0
|
|
|
|
|
? 'text-red-400'
|
|
|
|
|
: dayOfWeek === 6
|
|
|
|
|
? 'text-blue-400'
|
|
|
|
|
: 'text-gray-400'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{getDayName(date)}
|
|
|
|
|
</span>
|
|
|
|
|
<span
|
|
|
|
|
className={`text-sm font-semibold mt-0.5 ${
|
|
|
|
|
isSelected(date)
|
|
|
|
|
? 'text-white'
|
|
|
|
|
: isToday(date)
|
|
|
|
|
? 'text-primary'
|
|
|
|
|
: 'text-gray-700'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{date.getDate()}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="flex gap-0.5 mt-1 h-1.5">
|
|
|
|
|
{!isSelected(date) &&
|
|
|
|
|
daySchedules.map((schedule, i) => {
|
|
|
|
|
const categoryInfo = getCategoryInfo(schedule);
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
className="w-1 h-1 rounded-full"
|
|
|
|
|
style={{ backgroundColor: categoryInfo.color }}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 달력 팝업 */}
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
{showCalendar && !isSearchMode && (
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ height: 0, opacity: 0 }}
|
|
|
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
|
|
|
exit={{ height: 0, opacity: 0 }}
|
|
|
|
|
className="fixed left-0 right-0 bg-white shadow-lg z-50 border-b overflow-hidden"
|
|
|
|
|
style={{ top: '56px' }}
|
|
|
|
|
>
|
|
|
|
|
<MobileCalendar
|
|
|
|
|
selectedDate={selectedDate}
|
|
|
|
|
schedules={isSameMonth ? schedules : calendarSchedules}
|
|
|
|
|
hideHeader={true}
|
|
|
|
|
externalViewDate={calendarViewDate}
|
|
|
|
|
onViewDateChange={setCalendarViewDate}
|
|
|
|
|
externalShowYearMonth={calendarShowYearMonth}
|
|
|
|
|
onShowYearMonthChange={setCalendarShowYearMonth}
|
|
|
|
|
onSelectDate={(date) => {
|
|
|
|
|
setSelectedDate(date);
|
|
|
|
|
setCalendarViewDate(date);
|
|
|
|
|
setCalendarShowYearMonth(false);
|
|
|
|
|
setShowCalendar(false);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
|
|
|
|
{/* 캘린더 배경 오버레이 */}
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
{showCalendar && !isSearchMode && (
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
exit={{ opacity: 0 }}
|
|
|
|
|
onClick={() => setShowCalendar(false)}
|
|
|
|
|
className="fixed inset-0 bg-black/40 z-40"
|
|
|
|
|
style={{ top: 0 }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
|
|
|
|
{/* 컨텐츠 영역 */}
|
|
|
|
|
<div
|
|
|
|
|
className="mobile-content"
|
|
|
|
|
ref={isSearchMode && searchTerm && !showSuggestionsScreen ? scrollContainerRef : contentRef}
|
|
|
|
|
>
|
|
|
|
|
<div className={`px-4 pb-4 ${isSearchMode && showSuggestionsScreen ? 'pt-0' : 'pt-4'}`}>
|
|
|
|
|
{isSearchMode ? (
|
|
|
|
|
showSuggestionsScreen ? (
|
|
|
|
|
// 추천 검색어 화면
|
|
|
|
|
<div className="space-y-0">
|
|
|
|
|
{suggestions.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8 text-gray-400">검색어를 입력하세요</div>
|
|
|
|
|
) : (
|
|
|
|
|
suggestions.map((suggestion, index) => (
|
|
|
|
|
<button
|
|
|
|
|
key={suggestion}
|
|
|
|
|
onClick={() => handleSearch(suggestion)}
|
|
|
|
|
className={`w-full px-0 py-3.5 text-left flex items-center gap-4 border-b border-gray-100 active:bg-gray-50 ${
|
|
|
|
|
index === selectedSuggestionIndex ? 'bg-primary/5' : ''
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<Search size={18} className="text-gray-400 shrink-0" />
|
|
|
|
|
<span className="text-[15px] text-gray-700 flex-1">{suggestion}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : !searchTerm ? (
|
|
|
|
|
<div className="text-center py-8 text-gray-400">검색어를 입력하세요</div>
|
|
|
|
|
) : searchLoading ? (
|
|
|
|
|
<div className="flex justify-center py-8">
|
|
|
|
|
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
|
|
|
</div>
|
|
|
|
|
) : searchResults.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8 text-gray-400">검색 결과가 없습니다</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
|
|
|
width: '100%',
|
|
|
|
|
position: 'relative',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
|
|
|
|
const schedule = searchResults[virtualItem.index];
|
|
|
|
|
if (!schedule) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={virtualItem.key}
|
|
|
|
|
ref={virtualizer.measureElement}
|
|
|
|
|
data-index={virtualItem.index}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
width: '100%',
|
|
|
|
|
transform: `translateY(${virtualItem.start}px)`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className={virtualItem.index < searchResults.length - 1 ? 'pb-3' : ''}>
|
|
|
|
|
<MobileScheduleSearchCard
|
|
|
|
|
schedule={schedule}
|
|
|
|
|
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<div ref={loadMoreRef} className="py-2">
|
|
|
|
|
{isFetchingNextPage && (
|
|
|
|
|
<div className="flex justify-center">
|
|
|
|
|
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)
|
|
|
|
|
) : loading ? (
|
|
|
|
|
<div className="flex justify-center py-8">
|
|
|
|
|
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
|
|
|
</div>
|
|
|
|
|
) : selectedDateSchedules.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8 text-gray-400">
|
|
|
|
|
{selectedDate.getMonth() + 1}월 {selectedDate.getDate()}일 일정이 없습니다
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{selectedDateSchedules.map((schedule, index) => {
|
|
|
|
|
const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
|
2026-01-24 15:04:29 +09:00
|
|
|
const isDebut = schedule.is_debut || schedule.is_anniversary;
|
2026-01-22 11:32:43 +09:00
|
|
|
|
|
|
|
|
if (isBirthday) {
|
|
|
|
|
return (
|
|
|
|
|
<MobileBirthdayCard
|
|
|
|
|
key={schedule.id}
|
|
|
|
|
schedule={schedule}
|
|
|
|
|
delay={index * 0.05}
|
2026-01-25 13:15:04 +09:00
|
|
|
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
2026-01-22 11:32:43 +09:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 15:04:29 +09:00
|
|
|
if (isDebut) {
|
|
|
|
|
return (
|
|
|
|
|
<MobileDebutCard
|
|
|
|
|
key={schedule.id}
|
|
|
|
|
schedule={schedule}
|
|
|
|
|
delay={index * 0.05}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 11:32:43 +09:00
|
|
|
return (
|
|
|
|
|
<MobileScheduleListCard
|
|
|
|
|
key={schedule.id}
|
|
|
|
|
schedule={schedule}
|
|
|
|
|
delay={index * 0.05}
|
|
|
|
|
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-24 15:04:29 +09:00
|
|
|
|
|
|
|
|
{/* 데뷔/주년 축하 다이얼로그 */}
|
|
|
|
|
<DebutCelebrationDialog
|
|
|
|
|
isOpen={showDebutDialog}
|
|
|
|
|
onClose={() => setShowDebutDialog(false)}
|
|
|
|
|
isDebut={debutDialogInfo.isDebut}
|
|
|
|
|
anniversaryYear={debutDialogInfo.anniversaryYear}
|
|
|
|
|
/>
|
2026-03-20 13:39:55 +09:00
|
|
|
{/* 생일 축하 다이얼로그 */}
|
|
|
|
|
<BirthdayCelebrationDialog
|
|
|
|
|
isOpen={showBirthdayDialog}
|
|
|
|
|
onClose={() => setShowBirthdayDialog(false)}
|
|
|
|
|
title={birthdayInfo.title}
|
|
|
|
|
memberImage={birthdayInfo.memberImage}
|
|
|
|
|
date={birthdayInfo.date}
|
|
|
|
|
/>
|
2026-01-22 11:32:43 +09:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default MobileSchedule;
|