diff --git a/src/api/theater/theater.api.ts b/src/api/theater/theater.api.ts index 4fe8e0a..c641375 100644 --- a/src/api/theater/theater.api.ts +++ b/src/api/theater/theater.api.ts @@ -1,5 +1,5 @@ import api from '@/api/api'; -import type { ApiResponse } from '@/types/api-response'; +import type { ApiResponse, PaginationData } from '@/types/api-response'; import type { CinemaFormat } from '@/types/onboarding'; import type { Theater } from '@/types/theater'; @@ -14,8 +14,8 @@ const getTheaters = async ({ type, page = 1, size = 10, -}: GetTheatersParams): Promise => { - const res = await api.get>('/theaters', { +}: GetTheatersParams): Promise> => { + const res = await api.get>>('/theaters', { params: { auditoriumType: type, page, @@ -23,13 +23,20 @@ const getTheaters = async ({ }, }); - const content = res.data.data?.content; + console.log('getTheaters API 응답', res.data.data); + + const { content, hasNext, page: currentPage, size: pageSize } = res.data.data; if (!content) { throw new Error('영화관 목록에 응답이 없습니다.'); } - return content; + return { + content, + hasNext, + page: currentPage, + size: pageSize, + }; }; // 좌석 배치도 조회 diff --git a/src/components/common/Theater/TheaterList.tsx b/src/components/common/Theater/TheaterList.tsx index 87eed38..dc7bed7 100644 --- a/src/components/common/Theater/TheaterList.tsx +++ b/src/components/common/Theater/TheaterList.tsx @@ -6,13 +6,14 @@ interface Props { data: Theater[]; selected: string[]; onSelect: (auditoriumId: string) => void; - onAuditoriumClick?: (auditoriumId: string) => void; // 클릭 prop 추가 + onAuditoriumClick?: (auditoriumId: string) => void; + observerRef?: React.RefObject; } const TheaterList = ({ data, selected, onSelect, onAuditoriumClick }: Props) => { const [expandedTheater, setExpandedTheater] = useState(null); - // theaterName 기준으로 그룹화 + // 그룹화: theaterName 기준 const grouped = data.reduce>((acc, cur) => { if (!acc[cur.theaterName]) acc[cur.theaterName] = []; acc[cur.theaterName].push(cur); @@ -47,27 +48,35 @@ const TheaterList = ({ data, selected, onSelect, onAuditoriumClick }: Props) => {/* 하위 관 목록 */} {isExpanded && (
- {auditoriums.map((auditorium) => { - const isSelected = isAuditoriumSelected(auditorium.auditoriumId); - - return ( - + {(() => { + // auditoriumId 기준 중복 제거 + const uniqueAuditoriums = Array.from( + new Map(auditoriums.map((a) => [a.auditoriumId, a])).values(), ); - })} + + return uniqueAuditoriums.map((auditorium) => { + const isSelected = isAuditoriumSelected(auditorium.auditoriumId); + + return ( + + ); + }); + })()}
)} diff --git a/src/hooks/queries/useTheatersQuery.ts b/src/hooks/queries/useTheatersQuery.ts index 47f22d5..67a8196 100644 --- a/src/hooks/queries/useTheatersQuery.ts +++ b/src/hooks/queries/useTheatersQuery.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { getTheaters } from '@/api/theater/theater.api'; +// import { getTheaters } from '@/api/theater/theater.api'; import type { GetTheatersParams } from '@/api/theater/theater.api'; import type { Theater } from '@/types/theater'; import type { ApiError } from '@/types/api-response'; @@ -8,7 +8,7 @@ import { useAfterQuery } from '../useAfterQuery'; export const useTheatersQuery = ({ type, page = 1, size = 10 }: GetTheatersParams) => { const query = useQuery({ queryKey: ['theaters', type, page, size], - queryFn: () => getTheaters({ type, page, size }), + // queryFn: () => getTheaters({ type, page, size }), enabled: !!type, // type이 없으면 쿼리 실행 X }); diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts new file mode 100644 index 0000000..daee6bb --- /dev/null +++ b/src/hooks/useInfiniteScroll.ts @@ -0,0 +1,83 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; + +interface FetchResponse { + content: T[]; + hasNext: boolean; + page: number; + size: number; +} + +interface UseInfiniteScrollProps { + fetchFunction: (page: number, size: number) => Promise>; + pageSize?: number; +} + +export default function useInfiniteScroll({ + fetchFunction, + pageSize = 10, +}: UseInfiniteScrollProps) { + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const observerRef = useRef(null); + const pageRef = useRef(1); + const hasNextRef = useRef(true); + const isLoadingRef = useRef(false); + + const loadMore = useCallback(async () => { + if (isLoadingRef.current || !hasNextRef.current) return; + + isLoadingRef.current = true; + setIsLoading(true); + + try { + const res = await fetchFunction(pageRef.current, pageSize); + setData((prev) => [...prev, ...res.content]); + + console.log('📦 API 응답:', res); + + hasNextRef.current = res.hasNext; + pageRef.current += 1; + } catch (err) { + console.error('무한 스크롤 에러:', err); + } finally { + isLoadingRef.current = false; + setIsLoading(false); + } + }, [fetchFunction, pageSize]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + loadMore(); + } + }, + { + rootMargin: '100px', + threshold: 0.3, + }, + ); + + const current = observerRef.current; + if (current) observer.observe(current); + + return () => { + if (current) observer.unobserve(current); + }; + }, [loadMore]); + + const reset = () => { + pageRef.current = 0; + hasNextRef.current = true; + isLoadingRef.current = false; + setData([]); + }; + + return { + data, + isLoading, + observerRef, // 컴포넌트 하단 요소에 연결 + reset, // 필요 시 외부에서 초기화 가능 + }; +} diff --git a/src/pages/home/TheatersList.tsx b/src/pages/home/TheatersList.tsx index be5560c..e4c2372 100644 --- a/src/pages/home/TheatersList.tsx +++ b/src/pages/home/TheatersList.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; -import { ToggleTab, Header, TheaterList } from '@/components'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { ToggleTab, Header, TheaterList } from '@/components'; import type { CinemaFormat } from '@/types/onboarding'; -import { useTheatersQuery } from '@/hooks/queries/useTheatersQuery'; +import { getTheaters } from '@/api/theater/theater.api'; +import type { Theater } from '@/types/theater'; export default function TheaterListPage() { const navigate = useNavigate(); @@ -13,8 +14,67 @@ export default function TheaterListPage() { const [selectedTab, setSelectedTab] = useState(initialTab); const [selectedAuditorium, setSelectedAuditorium] = useState(null); + const [theaters, setTheaters] = useState([]); + const [page, setPage] = useState(1); + const [hasNext, setHasNext] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const observerRef = useRef(null); + + 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, page, selectedTab]); + + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !isLoading && hasNext) { + loadMore(); + } + }, + { + rootMargin: '100px', + threshold: 0.7, + }, + ); - const { data: theaters } = useTheatersQuery({ type: selectedTab, page: 1, size: 10 }); + if (observerRef.current) observer.observe(observerRef.current); + + return () => { + if (observerRef.current) observer.unobserve(observerRef.current); + }; + }, [loadMore, isLoading, hasNext]); + + // 탭 변경 시 초기화 + 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]); const handleTabChange = (tab: string) => { setSelectedTab(tab as CinemaFormat); @@ -26,6 +86,7 @@ export default function TheaterListPage() {
navigate('/home')} className="bg-gray-900"> 영화관 리스트
+ {/* 탭 */}
@@ -39,17 +100,18 @@ export default function TheaterListPage() { />
-
- {/* 리스트 */} + {/* 리스트 */} +
setSelectedAuditorium(id)} onAuditoriumClick={(auditoriumId) => { navigate(`/theaters/${auditoriumId}`); }} /> + {hasNext &&
}
); diff --git a/src/pages/onboarding/OnboardingTheaterPage.tsx b/src/pages/onboarding/OnboardingTheaterPage.tsx index 5858985..92e7ca6 100644 --- a/src/pages/onboarding/OnboardingTheaterPage.tsx +++ b/src/pages/onboarding/OnboardingTheaterPage.tsx @@ -1,11 +1,12 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useOnboardingStore } from '@/store/useOnboardingStore'; -import { Button, ToggleTab, Header, ProgressBar } from '@/components'; +import { Button, ToggleTab, Header, ProgressBar, TheaterList } from '@/components'; import type { CinemaFormat } from '@/types/onboarding'; -import { useTheatersQuery } from '@/hooks/queries/useTheatersQuery'; -import TheaterList from '@/components/common/Theater/TheaterList'; +// import { useTheatersQuery } from '@/hooks/queries/useTheatersQuery'; import { useRegisterMutation } from '@/hooks/mutations/useRegisterMutation'; +import { getTheaters } from '@/api/theater/theater.api'; +import type { Theater } from '@/types/theater'; const OnboardingTheaterPage = () => { const navigate = useNavigate(); @@ -13,15 +14,75 @@ const OnboardingTheaterPage = () => { useOnboardingStore(); const [selectedTab, setSelectedTab] = useState('IMAX'); + const [theaters, setTheaters] = useState([]); + const [page, setPage] = useState(1); + const [hasNext, setHasNext] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const observerRef = useRef(null); - const { data: theaters } = useTheatersQuery({ type: selectedTab, page: 1, size: 10 }); const { mutate } = useRegisterMutation(); + 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); + } + }, [page, selectedTab, hasNext, isLoading]); + + 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]); + + // 탭 변경 시 초기화 + 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(1); + } catch (err) { + console.error('초기 로딩 실패:', err); + } + }; + + reset(); + setCinemaFormat(selectedTab); + }, [selectedTab]); + const handleToggleTab = (tab: string) => { const format = tab as CinemaFormat; if (format === selectedTab) return; setSelectedTab(format); - setCinemaFormat(format); }; const toggleTheater = (auditoriumId: string) => { @@ -38,7 +99,6 @@ const OnboardingTheaterPage = () => { if (selectedCinemas.length === 0) return; const tempUserKey = localStorage.getItem('tempKey'); - console.log('데이터: ', nickname, selectedGenres, selectedCinemas); if (!tempUserKey) { console.error('임시 유저 키가 없습니다.'); return; @@ -69,11 +129,10 @@ const OnboardingTheaterPage = () => {
{/* 상단 헤더 */}
- {/* 진행도 바 */} +
- {/* 콘텐츠 영역 */}

자주 가는 영화관을 선택해주세요

최대 2개까지 선택할 수 있어요.

@@ -90,7 +149,9 @@ const OnboardingTheaterPage = () => {
- + + + {hasNext &&
}
diff --git a/src/pages/review/CinemaSelect.tsx b/src/pages/review/CinemaSelect.tsx index dc779ad..0c27d59 100644 --- a/src/pages/review/CinemaSelect.tsx +++ b/src/pages/review/CinemaSelect.tsx @@ -1,18 +1,23 @@ import { useNavigate } from 'react-router-dom'; -import { ToggleTab, Button, ReviewStepLayout } from '@/components'; -import { useState, useEffect } from 'react'; +import { ToggleTab, ReviewStepLayout, TheaterList } from '@/components'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useReviewStore } from '@/store'; -import { groupCinemasByTheater } from '@/utils/groupCinemasByTheater'; +import { getTheaters } from '@/api/theater/theater.api'; +import type { CinemaFormat } from '@/types/onboarding'; +import type { Theater } from '@/types/theater'; export default function CinemaSelect() { const { isInitialized } = useReviewStore(); const navigate = useNavigate(); - const [selectedTab, setSelectedTab] = useState<'IMAX' | 'Dolby Cinema'>('IMAX'); - const [selectedCinema, setSelectedCinema] = useState(null); - const [selectedHall, setSelectedHall] = useState(null); + const [selectedTab, setSelectedTab] = useState('IMAX'); + const [selectedAuditorium, setSelectedAuditorium] = useState(null); + const [theaters, setTheaters] = useState([]); + const [page, setPage] = useState(1); + const [hasNext, setHasNext] = useState(true); + const [isLoading, setIsLoading] = useState(false); - const cinemas = groupCinemasByTheater(selectedTab); + const observerRef = useRef(null); useEffect(() => { if (!isInitialized) { @@ -20,16 +25,83 @@ export default function CinemaSelect() { } }, [isInitialized, navigate]); + 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, page, selectedTab]); + + 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]); + + // 탭 변경 시 초기화 + 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]); + + const handleTabChange = (tab: string) => { + setSelectedTab(tab as CinemaFormat); + setSelectedAuditorium(null); + }; + const handleNext = () => { - navigate('/review/info', { state: { cinema: { name: selectedCinema, hall: selectedHall } } }); + const selected = theaters?.find((d) => d.auditoriumId === selectedAuditorium); + if (!selected) return; + + navigate('/review/info', { + state: { + cinema: { + id: selected.auditoriumId, + name: selected.theaterName, + hall: selected.auditoriumName, + }, + }, + }); }; return ( - 1 && !selectedHall)} - nextLabel="선택 완료" - > + {/* 탭 */}
@@ -39,66 +111,18 @@ export default function CinemaSelect() { { label: 'Dolby Cinema', value: 'Dolby Cinema' }, ]} selected={selectedTab} - onSelect={(option) => { - setSelectedTab(option as 'IMAX' | 'Dolby Cinema'); - setSelectedCinema(null); - setSelectedHall(null); - }} + onSelect={handleTabChange} />
{/* 영화관 목록 */} -
-
- {Object.entries(cinemas).map(([theaterName, halls]) => { - const isThisSelected = selectedCinema === theaterName; - const isMulti = Array.isArray(halls) && halls.length > 1; - - return ( -
- - - {/* 상영관 선택 (2관 이상인 경우만 표시) */} - {isThisSelected && isMulti && ( -
- {halls.map((hall) => ( - - ))} -
- )} -
- ); - })} -
-
+ setSelectedAuditorium(id)} + /> + {hasNext &&
} ); } diff --git a/src/types/api-response.ts b/src/types/api-response.ts index a5d7a91..c733b0c 100644 --- a/src/types/api-response.ts +++ b/src/types/api-response.ts @@ -18,3 +18,12 @@ export interface ApiError { code: number | string; error: FieldError[] | null; } + +export interface PaginationData { + content: T[]; + hasNext: boolean; + page: number; + size: number; +} + +export type InfiniteApiResponse = ApiResponse>;