Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions src/api/theater/theater.api.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -14,22 +14,29 @@ const getTheaters = async ({
type,
page = 1,
size = 10,
}: GetTheatersParams): Promise<Theater[]> => {
const res = await api.get<ApiResponse<{ content: Theater[] }>>('/theaters', {
}: GetTheatersParams): Promise<PaginationData<Theater>> => {
const res = await api.get<ApiResponse<PaginationData<Theater>>>('/theaters', {
params: {
auditoriumType: type,
page,
size,
},
});

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,
};
};

// 좌석 배치도 조회
Expand Down
53 changes: 31 additions & 22 deletions src/components/common/Theater/TheaterList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>;
}

const TheaterList = ({ data, selected, onSelect, onAuditoriumClick }: Props) => {
const [expandedTheater, setExpandedTheater] = useState<string | null>(null);

// theaterName 기준으로 그룹화
// 그룹화: theaterName 기준
const grouped = data.reduce<Record<string, Theater[]>>((acc, cur) => {
if (!acc[cur.theaterName]) acc[cur.theaterName] = [];
acc[cur.theaterName].push(cur);
Expand Down Expand Up @@ -47,27 +48,35 @@ const TheaterList = ({ data, selected, onSelect, onAuditoriumClick }: Props) =>
{/* 하위 관 목록 */}
{isExpanded && (
<div className="mt-2 flex flex-wrap justify-end gap-2 px-1">
{auditoriums.map((auditorium) => {
const isSelected = isAuditoriumSelected(auditorium.auditoriumId);

return (
<Button
key={auditorium.auditoriumId}
onClick={() => {
if (onAuditoriumClick) {
onAuditoriumClick(auditorium.auditoriumId); // 페이지 이동
} else {
onSelect(auditorium.auditoriumId); // 선택만
}
}}
selected={isSelected}
variant="secondary-assistive"
size="md"
>
{auditorium.auditoriumName}
</Button>
{(() => {
// auditoriumId 기준 중복 제거
const uniqueAuditoriums = Array.from(
new Map(auditoriums.map((a) => [a.auditoriumId, a])).values(),
);
})}

return uniqueAuditoriums.map((auditorium) => {
const isSelected = isAuditoriumSelected(auditorium.auditoriumId);

return (
<Button
key={auditorium.auditoriumId}
onClick={() => {
if (onAuditoriumClick) {
onAuditoriumClick(auditorium.auditoriumId);
} else {
onSelect(auditorium.auditoriumId);
}
}}
selected={isSelected}
variant="secondary-assistive"
size="md"
className="min-w-2xs"
>
{auditorium.auditoriumName}
</Button>
);
});
})()}
</div>
)}
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/queries/useTheatersQuery.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,7 +8,7 @@ import { useAfterQuery } from '../useAfterQuery';
export const useTheatersQuery = ({ type, page = 1, size = 10 }: GetTheatersParams) => {
const query = useQuery<Theater[], ApiError>({
queryKey: ['theaters', type, page, size],
queryFn: () => getTheaters({ type, page, size }),
// queryFn: () => getTheaters({ type, page, size }),
enabled: !!type, // type이 없으면 쿼리 실행 X
});

Expand Down
83 changes: 83 additions & 0 deletions src/hooks/useInfiniteScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState, useCallback } from 'react';

interface FetchResponse<T> {
content: T[];
hasNext: boolean;
page: number;
size: number;
}

interface UseInfiniteScrollProps<T> {
fetchFunction: (page: number, size: number) => Promise<FetchResponse<T>>;
pageSize?: number;
}

export default function useInfiniteScroll<T>({
fetchFunction,
pageSize = 10,
}: UseInfiniteScrollProps<T>) {
const [data, setData] = useState<T[]>([]);
const [isLoading, setIsLoading] = useState(false);

const observerRef = useRef<HTMLDivElement | null>(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, // 필요 시 외부에서 초기화 가능
};
}
76 changes: 69 additions & 7 deletions src/pages/home/TheatersList.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -13,8 +14,67 @@ export default function TheaterListPage() {

const [selectedTab, setSelectedTab] = useState<CinemaFormat>(initialTab);
const [selectedAuditorium, setSelectedAuditorium] = useState<string | null>(null);
const [theaters, setTheaters] = useState<Theater[]>([]);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(true);
const [isLoading, setIsLoading] = useState(false);

const observerRef = useRef<HTMLDivElement | null>(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);
Expand All @@ -26,6 +86,7 @@ export default function TheaterListPage() {
<Header leftSection="BACK" onBackClick={() => navigate('/home')} className="bg-gray-900">
영화관 리스트
</Header>

{/* 탭 */}
<div className="flex justify-center px-5 pt-5">
<div className="w-[375px]">
Expand All @@ -39,17 +100,18 @@ export default function TheaterListPage() {
/>
</div>
</div>
<div className="mt-5 px-5">
{/* 리스트 */}

{/* 리스트 */}
<div className="mt-5 px-5">
<TheaterList
data={theaters ?? []}
data={theaters}
selected={selectedAuditorium ? [selectedAuditorium] : []}
onSelect={(id) => setSelectedAuditorium(id)}
onAuditoriumClick={(auditoriumId) => {
navigate(`/theaters/${auditoriumId}`);
}}
/>
{hasNext && <div ref={observerRef} className="h-[100px]" />}
</div>
</div>
);
Expand Down
Loading