diff --git a/src/components/common/Modal/SeatModal/SeatFocusModal.tsx b/src/components/common/Modal/SeatModal/SeatFocusModal.tsx index 198cef9..11bac43 100644 --- a/src/components/common/Modal/SeatModal/SeatFocusModal.tsx +++ b/src/components/common/Modal/SeatModal/SeatFocusModal.tsx @@ -17,6 +17,7 @@ const SeatFocusModal = ({ theaterName, selectedSeatNumbers, onClose, + focusedSeatIds, }: SeatFocusModalProps) => { const containerRef = useRef(null); const innerRef = useRef(null); @@ -24,23 +25,27 @@ const SeatFocusModal = ({ const seatIds = selectedSeatNumbers.map((num) => getSeatLabel(auditoriumId, num)); useEffect(() => { - const container = containerRef.current; - const target = container?.querySelector('[data-seat-focus="true"]') as HTMLDivElement; + const timer = setTimeout(() => { + const container = containerRef.current; + const target = container?.querySelector('[data-seat-focus="true"]') as HTMLDivElement; - if (container && target) { - const containerRect = container.getBoundingClientRect(); - const targetRect = target.getBoundingClientRect(); + if (container && target) { + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); - const scrollTop = container.scrollTop + (targetRect.top - containerRect.top) - 100; - const scrollLeft = container.scrollLeft + (targetRect.left - containerRect.left) - 50; + const scrollTop = container.scrollTop + (targetRect.top - containerRect.top) - 100; + const scrollLeft = container.scrollLeft + (targetRect.left - containerRect.left) - 50; - container.scrollTo({ - top: scrollTop, - left: scrollLeft, - behavior: 'smooth', - }); - } - }, []); + container.scrollTo({ + top: scrollTop, + left: scrollLeft, + behavior: 'smooth', + }); + } + }, 50); + + return () => clearTimeout(timer); + }, [focusedSeatIds]); return (
diff --git a/src/components/seat/SeatMap.tsx b/src/components/seat/SeatMap.tsx index f650b94..52b0d0c 100644 --- a/src/components/seat/SeatMap.tsx +++ b/src/components/seat/SeatMap.tsx @@ -1,15 +1,15 @@ import SeatRow from './SeatRow'; -import type { SeatRatingInfo } from '@/api/theater/theater.api'; +import { useEffect, useRef, useState } from 'react'; import type { Seat } from '@/types/seat'; +import type { SeatRatingInfo } from '@/api/theater/theater.api'; import { getSeatLayout } from '@/api/theater/theater.api'; -import { useEffect, useRef, useState } from 'react'; import type { ApiError } from '@/types/api-response'; interface SeatMapProps { auditoriumId?: string; onSeatClick?: (seatId: string) => void; - focusedSeatIds?: string[]; // seatFocus용 - selectedSeatNames?: string[]; // seatWrite 용 + focusedSeatIds?: string[]; + selectedSeatNames?: string[]; seatData?: SeatRatingInfo[]; type?: 'seatFocus' | 'seatPicker' | 'seatWrite'; } @@ -19,13 +19,13 @@ const SeatMap = ({ onSeatClick, focusedSeatIds = [], selectedSeatNames = [], - type = 'seatPicker', seatData = [], + type = 'seatPicker', }: SeatMapProps) => { const [autoSeatData, setAutoSeatData] = useState([]); - const focusedRef = useRef(null!); + const focusedRef = useRef(null); - // row별로 묶기 + // seatFocus 모드일 때만 서버에서 좌석 배치도 불러오기 useEffect(() => { const fetch = async () => { if (type === 'seatFocus' && auditoriumId) { @@ -35,7 +35,6 @@ const SeatMap = ({ ...seat, column: Number(seat.column), })); - setAutoSeatData(parsedData); } catch (error) { const apiError = error as ApiError; @@ -46,43 +45,39 @@ const SeatMap = ({ fetch(); }, [type, auditoriumId]); + // 사용할 seatData 결정 const dataToRender = type === 'seatFocus' ? autoSeatData : seatData; + // row별로 그룹핑 + 전체 column 범위 추출 const seatRows: Record = {}; + const allColumns = new Set(); + dataToRender.forEach((seat) => { if (!seatRows[seat.row]) seatRows[seat.row] = []; seatRows[seat.row].push(seat); + allColumns.add(seat.column); }); + const minColumn = Math.min(...Array.from(allColumns)); + const maxColumn = Math.max(...Array.from(allColumns)); + return (
{Object.entries(seatRows) .sort(([a], [b]) => a.localeCompare(b)) - .map(([row, seats]) => { - seats.sort((a, b) => a.column - b.column); - - const min = seats[0].column; - const max = seats[seats.length - 1].column; - - const filledRow: (Seat | null)[] = Array(max - min + 1).fill(null); - - seats.forEach((seat) => { - const index = seat.column - min; - filledRow[index] = seat; - }); - - return ( - - ); - })} + .map(([row, seats]) => ( + } + type={type} + minColumn={minColumn} + maxColumn={maxColumn} + /> + ))}
); }; diff --git a/src/components/seat/SeatRow.tsx b/src/components/seat/SeatRow.tsx index 87f0ee9..0c00c7a 100644 --- a/src/components/seat/SeatRow.tsx +++ b/src/components/seat/SeatRow.tsx @@ -3,12 +3,14 @@ import type { ReviewedSeat } from '@/types/seat'; import { getSeatLabel } from '@/utils/getSeatLabel'; interface SeatRowProps { - rowSeats: (ReviewedSeat | null)[]; + rowSeats: ReviewedSeat[]; onSeatClick?: (seatId: string) => void; focusedSeatIds?: string[]; selectedSeatNames?: string[]; focusedRef?: React.RefObject; type?: 'seatFocus' | 'seatPicker' | 'seatWrite'; + minColumn: number; + maxColumn: number; } const SeatRow = ({ @@ -18,10 +20,19 @@ const SeatRow = ({ selectedSeatNames = [], focusedRef, type = 'seatPicker', + minColumn, + maxColumn, }: SeatRowProps) => { + const filledRow: (ReviewedSeat | null)[] = Array(maxColumn - minColumn + 1).fill(null); + + rowSeats.forEach((seat) => { + const index = seat.column - minColumn; + filledRow[index] = seat; + }); + return (
- {rowSeats.map((seat, idx) => + {filledRow.map((seat, idx) => seat ? ( ('IMAX'); const [selectedAuditorium, setSelectedAuditorium] = useState(null); - const { data: theaters } = useTheatersQuery({ type: selectedTab, page: 1, size: 10 }); + // 무한스크롤용 상태 + const [theaters, setTheaters] = useState([]); + const [page, setPage] = useState(1); + const [hasNext, setHasNext] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const observerRef = useRef(null); + // 초기화 조건 useEffect(() => { if (!isInitialized) { navigate('/review'); } }, [isInitialized, navigate]); + // theater 목록 초기화 (탭 변경 시) + useEffect(() => { + const reset = async () => { + setPage(1); + setTheaters([]); + setHasNext(true); + try { + const res = await getTheaters({ type: selectedTab, page: 1, size: 10 }); + setTheaters(res.content); + setHasNext(res.hasNext); + setPage(2); + } catch (err) { + console.error('초기 로딩 실패:', err); + } + }; + reset(); + }, [selectedTab]); + + // theater 추가 로딩 + const loadMore = useCallback(async () => { + if (isLoading || !hasNext) return; + setIsLoading(true); + try { + const res = await getTheaters({ type: selectedTab, page, size: 10 }); + setTheaters((prev) => [...prev, ...res.content]); + setHasNext(res.hasNext); + setPage((prev) => prev + 1); + } catch (err) { + console.error('추가 로딩 실패:', err); + } finally { + setIsLoading(false); + } + }, [isLoading, hasNext, selectedTab, page]); + + // IntersectionObserver 연결 + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !isLoading && hasNext) { + loadMore(); + } + }, + { rootMargin: '100px', threshold: 0.7 }, + ); + + if (observerRef.current) observer.observe(observerRef.current); + + return () => { + if (observerRef.current) observer.unobserve(observerRef.current); + }; + }, [loadMore, isLoading, hasNext]); + + // 탭 변경 const handleTabChange = (tab: string) => { setSelectedTab(tab as CinemaFormat); setSelectedAuditorium(null); }; + // 다음 단계로 이동 const handleNext = () => { - const selected = theaters?.find((d) => d.auditoriumId === selectedAuditorium); + const selected = theaters.find((d) => d.auditoriumId === selectedAuditorium); if (!selected) return; navigate('/review/info', { @@ -58,10 +119,11 @@ export default function CinemaSelect() { {/* 영화관 목록 */} setSelectedAuditorium(id)} /> + {hasNext &&
} ); }