diff --git a/src/feature/performanceNews/performanceList/api.ts b/src/feature/performanceNews/performanceList/api.ts index 242e170..9dcd261 100644 --- a/src/feature/performanceNews/performanceList/api.ts +++ b/src/feature/performanceNews/performanceList/api.ts @@ -1,7 +1,7 @@ // src/feature/performanceList/api.ts -import { api } from '@/api/api'; -import type { PerformanceListQuery, PerformanceListResponse } from './types'; +import { api } from "@/api/api"; +import type { PerformanceListQuery, PerformanceListResponse } from "./types"; export const getPerformanceList = async ( query: PerformanceListQuery @@ -9,11 +9,11 @@ export const getPerformanceList = async ( const { status, isUsed, page = 0 } = query; console.log(isUsed); - const res = await api.get('/performance/', { + const res = await api.get("/performance/", { params: { status, - ...(typeof isUsed === 'boolean' ? { isUsed } : {}), + ...(typeof isUsed === "boolean" ? { isUsed } : {}), page, }, }); diff --git a/src/feature/performanceNews/performanceList/types.ts b/src/feature/performanceNews/performanceList/types.ts index 1d2e742..4669aca 100644 --- a/src/feature/performanceNews/performanceList/types.ts +++ b/src/feature/performanceNews/performanceList/types.ts @@ -1,6 +1,6 @@ // src/feature/performanceList/types.ts -export type PerformanceStatus = 'ONGOING' | 'UPCOMING' | 'PAST'; +export type PerformanceStatus = "ONGOING" | "UPCOMING" | "PAST"; export type PerformanceListQuery = { status: PerformanceStatus; @@ -14,8 +14,10 @@ export type PerformanceListItem = { title: string; place: string; startDate: string; // 'YYYY-MM-DD' - endDate: string; // 'YYYY-MM-DD' + endDate: string; // 'YYYY-MM-DD' isUsed: boolean; + isOwner: boolean; + link: string; }; export type PerformanceListResponse = PerformanceListItem[]; diff --git a/src/pages/performanceNews/performanceNews.tsx b/src/pages/performanceNews/performanceNews.tsx index a6214c5..d8bf20b 100644 --- a/src/pages/performanceNews/performanceNews.tsx +++ b/src/pages/performanceNews/performanceNews.tsx @@ -6,8 +6,7 @@ import type { PerformanceMainItem } from "@/feature/performanceNews/performanceM import { usePerformanceListInfinite } from "@/feature/performanceNews/performanceList/queries"; import link from "@/assets/image/ic_link.svg"; import { useCallback, useMemo, useRef, useState, useEffect } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import { usePerformanceDetail } from "@/feature/performanceNews/performanceDetail/queries"; +import { useNavigate, useSearchParams, type NavigateFunction } from "react-router-dom"; import clsx from "clsx"; import Cookies from "js-cookie"; import LinkModal from "@/components/modal/linkModal"; @@ -27,21 +26,393 @@ type PerformanceCardItem = { startDate: string; endDate: string; isUsed: boolean; + isOwner: boolean; + link: string; }; +/** ========================= + * ✅ PerformanceCard (TOP LEVEL) + * ========================= */ +const PerformanceCard = ({ + item, + onOpen, + onClose, + isOver480, + mobileOpen, + setPendingLink, + setIsLinkModalOpen, + navigate, +}: { + item: PerformanceCardItem; + mobileOpen: boolean; + onOpen: () => void; + onClose: () => void; + isOver480: boolean; + setPendingLink: (v: string | null) => void; + setIsLinkModalOpen: (v: boolean) => void; + navigate: NavigateFunction; +}) => { + const [isHovering, setIsHovering] = useState(false); + const isOpen = isOver480 ? isHovering : mobileOpen; + + const normalizationDate = () => { + const [sy, sm, sd] = item.startDate.split("-"); + const [, em, ed] = item.endDate.split("-"); + return `${sy}.${sm}.${sd}~${em}.${ed}`; + }; + + const handleGoLink = (e: React.MouseEvent) => { + e.stopPropagation(); + const url = item?.link; + if (!url) return; + + setPendingLink(url); + setIsLinkModalOpen(true); + }; + + const handleGoModify = (e: React.MouseEvent) => { + e.stopPropagation(); + onClose(); + navigate(`/performanceNews/edit/${item.id}`); + }; + + return ( +
{ + if (!isOver480) return; + setIsHovering(true); + }} + onMouseLeave={() => { + if (!isOver480) return; + setIsHovering(false); + }} + onClick={() => { + if (isOver480) return; // 데스크탑: hover로만 + onOpen(); // 모바일: click으로 open + }} + > +
+ {item.title} + + {/* ✅ 항상 렌더 + opacity만 토글 (200ms dissolve) */} +
{ + e.stopPropagation(); + // 모바일에서만 배경 클릭 닫기 + if (!isOver480) onClose(); + }} + > + {item?.isOwner && ( + + )} + + +
+
+ + {/* 텍스트 영역 */} +
+

+ {item.title} +

+

+ {item.place} +

+

+ {normalizationDate()} +

+ + {item.isUsed && ( +
+ 포도상점 +
+ )} +
+
+ ); +}; + +/** ========================= + * ✅ ViewAllSections (TOP LEVEL) + * ========================= */ +const ViewAllSections = ({ + title, + onClickSeeAll, + items, + filter, + onChangeFilter, + isLoading, + isError, + + isOver480, + openCardId, + setOpenCardId, + setPendingLink, + setIsLinkModalOpen, + navigate, +}: { + title: string; + onClickSeeAll: () => void; + items: PerformanceMainItem[]; + + filter: UsedFilter; + onChangeFilter: (next: UsedFilter) => void; + + isLoading: boolean; + isError: boolean; + + isOver480: boolean; + openCardId: string | null; + setOpenCardId: (id: string | null) => void; + setPendingLink: (v: string | null) => void; + setIsLinkModalOpen: (v: boolean) => void; + navigate: NavigateFunction; +}) => { + const list = items.slice(0, 4); + + return ( +
+
+

{title}

+ +
+
+ + + + + +
+ + +
+
+ + {isLoading &&
불러오는 중...
} + + {!isLoading && isError && ( +
불러오기에 실패했어요.
+ )} + + {!isLoading && !isError && items.length === 0 && ( +
존재하는 공연이 없습니다.
+ )} + + {!isLoading && !isError && ( +
+ {list.map((item) => ( + setOpenCardId(item.id)} + onClose={() => setOpenCardId(null)} + isOver480={isOver480} + setPendingLink={setPendingLink} + setIsLinkModalOpen={setIsLinkModalOpen} + navigate={navigate} + /> + ))} +
+ )} + + +
+ ); +}; + +/** ========================= + * ✅ ViewSingleSections (TOP LEVEL) + * ========================= */ +const ViewSingleSections = ({ + title, + status, + isOver480, + openCardId, + setOpenCardId, + setPendingLink, + setIsLinkModalOpen, + navigate, +}: { + title: string; + status: "ONGOING" | "UPCOMING" | "PAST"; + isOver480: boolean; + openCardId: string | null; + setOpenCardId: (id: string | null) => void; + setPendingLink: (v: string | null) => void; + setIsLinkModalOpen: (v: boolean) => void; + navigate: NavigateFunction; +}) => { + const [isUsed, setIsUsed] = useState(undefined); + + const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = + usePerformanceListInfinite({ status, isUsed }); + + const items = useMemo(() => data?.pages.flat() ?? [], [data]); + + const bottomRef = useRef(null); + + useEffect(() => { + const el = bottomRef.current; + if (!el) return; + + const io = new IntersectionObserver( + (entries) => { + const first = entries[0]; + if (first.isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage(); + }, + { threshold: 0.2 } + ); + + io.observe(el); + return () => io.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + if (isLoading) return <>로딩; + if (isError) return <>에러; + + return ( +
+
+

{title}

+
+
+

setIsUsed(undefined)} + > + 전체 +

+ + + +

setIsUsed(true)} + > + 포도상점 +

+
+
+
+ + {items.length === 0 &&
존재하는 공연이 없습니다.
} + +
+ {items.map((item) => ( + setOpenCardId(item.id)} + onClose={() => setOpenCardId(null)} + isOver480={isOver480} + setPendingLink={setPendingLink} + setIsLinkModalOpen={setIsLinkModalOpen} + navigate={navigate} + /> + ))} +
+ +
+ + {isFetchingNextPage &&
불러오는 중...
} +
+ ); +}; + +/** ========================= + * ✅ PerformanceNews (MAIN) + * ========================= */ const PerformanceNews = () => { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); + const [isOver480, setIsOver480] = useState(() => window.innerWidth > 480); const [sectionUsedFilter, setSectionUsedFilter] = useState>({ ongoing: "all", upcoming: "all", past: "all", }); + const toIsUsedParam = (filter: UsedFilter) => (filter === "podo" ? true : false); const activeTab = searchParams.get("tab") || "전체"; const accessToken = Cookies.get("accessToken"); + const tabToStatus = (tab: string) => { if (tab === "지금 공연중") return "ONGOING"; if (tab === "공연 예정") return "UPCOMING"; @@ -49,6 +420,7 @@ const PerformanceNews = () => { }; const [openCardId, setOpenCardId] = useState(null); + const { ongoing, upcoming, past, section } = usePerformanceMainSections({ ongoing: { isUsed: toIsUsedParam(sectionUsedFilter.ongoing) }, upcoming: { isUsed: toIsUsedParam(sectionUsedFilter.upcoming) }, @@ -56,8 +428,10 @@ const PerformanceNews = () => { }); const data = useMemo(() => ({ ongoing, upcoming, past }), [ongoing, upcoming, past]); + const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); const [pendingLink, setPendingLink] = useState(null); + const handleChangeCategory = useCallback( (value: string, type: "tab") => { const next = new URLSearchParams(searchParams.toString()); @@ -67,6 +441,16 @@ const PerformanceNews = () => { [searchParams, setSearchParams] ); + useEffect(() => { + const onResize = () => setIsOver480(window.innerWidth > 480); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + useEffect(() => { + setOpenCardId(null); + }, [activeTab, sectionUsedFilter]); + const handleSeeAll = useCallback( (tabName: string) => { handleChangeCategory(tabName, "tab"); @@ -76,343 +460,18 @@ const PerformanceNews = () => { const ALL_SECTIONS: SectionConfig[] = useMemo( () => [ - { - id: "ongoing", - title: "지금 공연중", - onClickSeeAll: () => handleSeeAll("지금 공연중"), - }, - { - id: "upcoming", - title: "공연 예정", - onClickSeeAll: () => handleSeeAll("공연 예정"), - }, - { - id: "past", - title: "지난 공연", - onClickSeeAll: () => handleSeeAll("지난 공연"), - }, + { id: "ongoing", title: "지금 공연중", onClickSeeAll: () => handleSeeAll("지금 공연중") }, + { id: "upcoming", title: "공연 예정", onClickSeeAll: () => handleSeeAll("공연 예정") }, + { id: "past", title: "지난 공연", onClickSeeAll: () => handleSeeAll("지난 공연") }, ], [handleSeeAll] ); - type ViewAllSectionsProps = { - sectionId: SectionId; - title: string; - onClickSeeAll: () => void; - items: PerformanceMainItem[]; - - filter: UsedFilter; - onChangeFilter: (next: UsedFilter) => void; - - isLoading: boolean; - isError: boolean; - }; - - const ViewAllSections = ({ - sectionId, - title, - onClickSeeAll, - items, - filter, - onChangeFilter, - isLoading, - isError, - }: ViewAllSectionsProps) => { - const list = items.slice(0, 4); - - return ( -
-
-

{title}

- -
-
- - - - - -
- - -
-
- {isLoading &&
불러오는 중...
} - - {!isLoading && isError && ( -
불러오기에 실패했어요.
- )} - - {!isLoading && !isError && items.length === 0 && ( -
존재하는 공연이 없습니다.
- )} - - {!isLoading && !isError && ( -
- {list.map((item) => ( - setOpenCardId((prev) => (prev === item.id ? null : item.id))} - onClose={() => setOpenCardId(null)} - /> - ))} -
- )} - - -
- ); - }; - - const PerformanceCard = ({ - item, - isOpen, - onToggle, - onClose, - }: { - item: PerformanceCardItem; - isOpen: boolean; - onToggle: () => void; - onClose: () => void; - }) => { - const { data: detailData } = usePerformanceDetail(item.id, isOpen); - - function normalizationDate() { - const splitEndDate = item.endDate.split("-"); - const finalEndDate = `${splitEndDate[1]}.${splitEndDate[2]}`; - const splitStartDate = item.startDate.split("-"); - const finalStartDate = `${splitStartDate[0]}.${splitStartDate[1]}.${splitStartDate[2]}`; - return `${finalStartDate}~${finalEndDate}`; - } - - const handleGoLink = (e: React.MouseEvent) => { - e.stopPropagation(); - - const url = detailData?.link; - if (!url) return; // 링크 없으면 아무것도 안 함 (또는 토스트) - - setPendingLink(url); - setIsLinkModalOpen(true); - }; - - const handleGoModify = (e: React.MouseEvent) => { - e.stopPropagation(); - onClose(); - navigate(`/performanceNews/edit/${item.id}`); - }; - - return ( -
{ - setOpenCardId(item.id); - console.log(detailData); - }} // ✅ 무조건 열기 - > -
- {item.title} - - {/* ✅ 오버레이 버튼 */} - {isOpen && ( -
{ - e.stopPropagation(); // ✅ article onClick으로 안 올라가게 막기 - onClose(); // ✅ 닫기 - }} - > - {detailData?.isOwner && ( - - )} - - -
- )} -
-
-

- {item.title} -

-

- {item.place} -

-

- {normalizationDate()} -

- - {item.isUsed && ( -
- 포도상점 -
- )} -
-
- ); - }; - - const ViewSingleSections = ({ title }: { title: string }) => { - const status = useMemo(() => tabToStatus(title), [title]); - const [isUsed, setIsUsed] = useState(undefined); - const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = - usePerformanceListInfinite({ status, isUsed }); - - const items = useMemo(() => data?.pages.flat() ?? [], [data]); - - const bottomRef = useRef(null); - - useEffect(() => { - const el = bottomRef.current; - if (!el) return; - - const io = new IntersectionObserver( - (entries) => { - const first = entries[0]; - if (first.isIntersecting && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }, - { threshold: 0.2 } - ); - - io.observe(el); - return () => io.disconnect(); - }, [fetchNextPage, hasNextPage, isFetchingNextPage]); - - if (isLoading) return <>로딩; - if (isError) return <>에러; - - //

- return ( -

-
-

{title}

- {/* ❗ button 안에 button 금지 → 바깥은 div로 */} -
-
-

setIsUsed(undefined)} - > - 전체 -

- - - -

setIsUsed(true)} - > - 포도상점 -

-
-
-
- - {items.length === 0 &&
존재하는 공연이 없습니다.
} -
- {items.map((item) => ( - setOpenCardId((prev) => (prev === item.id ? null : item.id))} - onClose={() => setOpenCardId(null)} - /> - ))} -
- - {/* ✅ sentinel */} -
- - {isFetchingNextPage &&
불러오는 중...
} -
- ); - }; + const singleStatus = useMemo(() => tabToStatus(activeTab), [activeTab]); return ( -
- {/* 상단 Info */} -
+
+

공연 소식

+ - {/* 메뉴 선택 */} + - {/* 전체 */} {activeTab === "전체" && (
{ALL_SECTIONS.map((sectionConfig) => ( { onChangeFilter={(next) => setSectionUsedFilter((prev) => ({ ...prev, [sectionConfig.id]: next })) } - isLoading={section[sectionConfig.id].isFetching} // ✅ 필터 전환 시에도 로딩 보이게: isFetching 추천 + isLoading={section[sectionConfig.id].isFetching} isError={section[sectionConfig.id].isError} + isOver480={isOver480} + openCardId={openCardId} + setOpenCardId={setOpenCardId} + setPendingLink={setPendingLink} + setIsLinkModalOpen={setIsLinkModalOpen} + navigate={navigate} /> ))}
)} - {/* 지금 공연중 */} - {activeTab === "지금 공연중" && } - {/* 공연 예정 */} - {activeTab === "공연 예정" && } - {/* 지난 공연 */} - {activeTab === "지난 공연" && } -
+ + {activeTab !== "전체" && ( + + )} + +
{ onClose={() => { setIsLinkModalOpen(false); setPendingLink(null); - setOpenCardId(null); // 원하면 카드 오버레이도 같이 닫기 + setOpenCardId(null); }} onConfirm={() => { if (pendingLink) window.open(pendingLink, "_blank", "noopener,noreferrer");