Repository: Ever-Ring/EverRing
Pull request #139: ✨ 상세페이지 수정사항 반영
Author: login: imsoohyeok
Created: 2025-03-11T05:13:39Z | Merged: 2025-03-11T06:28:01Z

## 📝작업 내용
상세페이지 수정사항 반영 작업 했습니다.

- [x] 모임 이미지, container-information 상단 마진
- [x] 모임 참여자가 상세페이지에 다시 들어왔을 경우 참여하기 버튼 -> 참여 취소하기 버튼 처리
- [x] 참여한 모임 새로고침 시 참여하기 버튼 -> 참여 취소하기 버튼 유지 되도록 수정
- [x] 마감 기한이 지난 모임의 경우 참여하기 버튼 비활성화
- [x] 취소하기 같은 딜리트 이벤트 시 한번 더 권유하기 (정말 취소하겠습니까?)
- [x] 불필요한 상태 업데이트 및 useEffect 사용 수정
- [x] window.location.href -> router.push 로 변경
- [x] page.tsx 파일 컨벤션 통일되도록 수정
- [x] hooks 폴더에 파일 생성하여 코드 분리 -> 가독성 향상


### 스크린샷 (선택)
![image](https://github.com/user-attachments/assets/5e2a35b1-1816-4cd2-8409-f4571ad58d40)
![image](https://github.com/user-attachments/assets/0c1ca820-1bc2-41f3-9a39-f9cff2afc2a2)
![image](https://github.com/user-attachments/assets/138d2340-66ee-4495-9ee4-5bd5364ee310)

## 💬리뷰 요구사항(선택)
제가 놓친 부분이나 추가적인 수정사항 생기면 편하게 말씀해주세요!


Diff:
diff --git a/app/(layout_dafault)/list-detail/[gatheringId]/page.tsx b/app/(layout_dafault)/list-detail/[gatheringId]/page.tsx
index 218d4f1..3739f38 100644
--- a/app/(layout_dafault)/list-detail/[gatheringId]/page.tsx
+++ b/app/(layout_dafault)/list-detail/[gatheringId]/page.tsx
@@ -1,36 +1,27 @@
+import { notFound } from "next/navigation";
+import { ReactElement } from "react";
+import { axiosInstance } from "@lib/axios";
 import { Gathering } from "@customTypes/gathering";
 import ListDetailContent from "@features/list-detail/components/ListDetailContent";
-import { axiosInstance } from "@lib/axios";
-import { notFound } from "next/navigation";
-import { JSX } from "react";
 
 export default async function ListDetailPage({
   params,
 }: {
   params: Promise<{ gatheringId: string }>;
-}): Promise<JSX.Element> {
+}): Promise<ReactElement> {
   const { gatheringId } = await params;
 
   if (!gatheringId) {
     notFound();
   }
 
-  try {
-    const response = await axiosInstance.get(`/gatherings/${gatheringId}`);
-    const gatheringData: Gathering = response.data;
+  const response = await axiosInstance.get(`/gatherings/${gatheringId}`);
+  const gatheringData: Gathering = response.data;
 
-    if (!gatheringData) {
-      notFound();
-    }
-
-    return (
-      <ListDetailContent
-        gatheringId={Number(gatheringId)}
-        gathering={gatheringData}
-      />
-    );
-  } catch (error) {
-    console.error("API fetch error:", error);
-    notFound();
-  }
+  return (
+    <ListDetailContent
+      gatheringId={Number(gatheringId)}
+      gathering={gatheringData}
+    />
+  );
 }
diff --git a/features/list-detail/components/ContainerInformation.tsx b/features/list-detail/components/ContainerInformation.tsx
index 4489ac5..7ec902c 100644
--- a/features/list-detail/components/ContainerInformation.tsx
+++ b/features/list-detail/components/ContainerInformation.tsx
@@ -20,7 +20,7 @@ interface ContainerInformationProps {
   time: string;
 }
 
-function ContainerInformation({
+export default function ContainerInformation({
   maxCount,
   gatheringId,
   title,
@@ -186,5 +186,3 @@ function ContainerInformation({
     </div>
   );
 }
-
-export default ContainerInformation;
diff --git a/features/list-detail/components/FloatingBar.tsx b/features/list-detail/components/FloatingBar.tsx
index c483ba9..b040d40 100644
--- a/features/list-detail/components/FloatingBar.tsx
+++ b/features/list-detail/components/FloatingBar.tsx
@@ -7,13 +7,13 @@ interface FloatingBarProps {
   isFull: boolean;
   onJoin: () => void;
   onCancel: () => void;
-  onDeleteJoined: () => void; // 참여 취소
+  onDeleteJoined: () => void;
   isJoining: boolean;
   isCancelling: boolean;
   onShare: () => void;
 }
 
-function FloatingBar({
+export default function FloatingBar({
   isTwoButtonMode = false,
   isJoined,
   isFull,
@@ -35,7 +35,7 @@ function FloatingBar({
       <Button
         text="참여 취소하기"
         size="small"
-        onClick={onDeleteJoined} // 참여 취소
+        onClick={onDeleteJoined}
         variant="outlined"
       />
     );
@@ -103,5 +103,3 @@ function FloatingBar({
     </div>
   );
 }
-
-export default FloatingBar;
diff --git a/features/list-detail/components/ListDetailContent.tsx b/features/list-detail/components/ListDetailContent.tsx
index 103e3c7..d69f24e 100644
--- a/features/list-detail/components/ListDetailContent.tsx
+++ b/features/list-detail/components/ListDetailContent.tsx
@@ -1,19 +1,22 @@
 "use client";
 
-import React, { useState, useEffect } from "react";
+import React from "react";
 import Image from "next/image";
+import AlertModal from "@components/common/AlertModal";
+import useUserStore from "@stores/userStore";
 import ContainerInformation from "@features/list-detail/components/ContainerInformation";
 import ReviewSection from "@features/list-detail/components/ReviewSection";
 import FloatingBar from "@features/list-detail/components/FloatingBar";
 import GatheringStatusBadge from "@features/list/components/GatheringStatusBadge";
+import useGetParticipants from "@features/list-detail/hooks/useGetParticipants";
 import useGetGatheringDetail from "@features/list-detail/hooks/useGetGatheringDetail";
 import useJoinGathering from "@features/list-detail/hooks/useJoinGathering";
 import useCancelGathering from "@features/list-detail/hooks/useCancelGathering";
+import useClickHandlers from "@features/list-detail/hooks/useClickHandlers";
+import useModalState from "@features/list-detail/hooks/useModalState";
 import { useDeleteGatheringJoined } from "@features/mypage/hooks/useDeleteGatheringJoinded";
-import useGetUserInfo from "@features/mypage/hooks/useGetUserInfo";
 import { Gathering } from "@customTypes/gathering";
 import { formatDateTime } from "@utils/dateFormatter";
-import AlertModal from "@components/common/AlertModal";
 
 interface ListDetailContentProps {
   gatheringId: number;
@@ -24,74 +27,65 @@ export default function ListDetailContent({
   gatheringId,
   gathering,
 }: ListDetailContentProps) {
+  const userData = useUserStore();
   const { data, isLoading, isError, error } =
     useGetGatheringDetail(gatheringId);
-  const { data: userData } = useGetUserInfo();
-  const [isJoined, setIsJoined] = useState(false);
-  const [isFull, setIsFull] = useState(false);
-  const [isModalOpen, setIsModalOpen] = useState(false);
+  const { data: participants } = useGetParticipants(gatheringId);
+
+  const { modalConfig, setModalConfig } = useModalState();
 
   const { mutate: joinGathering, status: joinStatus } = useJoinGathering();
   const { mutate: cancelGathering, status: cancelStatus } =
     useCancelGathering();
   const { mutate: deleteJoined } = useDeleteGatheringJoined();
 
-  useEffect(() => {
-    if (data) {
-      setIsFull(data.participantCount >= data.capacity);
-      setIsJoined(false);
-    }
-  }, [data]);
-
-  if (isLoading) return <div>로딩 중...</div>;
-
+  const {
+    handleJoinClick,
+    handleDeleteJoinedClick,
+    handleCancelClick,
+    handleShareClick,
+  } = useClickHandlers({
+    userData,
+    isFull: data ? data.participantCount >= data.capacity : false,
+    joinStatus,
+    cancelStatus,
+    gatheringId,
+    joinGathering,
+    cancelGathering,
+    deleteJoined,
+    setIsJoined: () => {},
+    setModalConfig,
+  });
+
+  const isRegistrationEnded = new Date(gathering.registrationEnd) < new Date();
+
+  const isFull = data
+    ? data.participantCount >= data.capacity || isRegistrationEnded
+    : false;
+
+  const isJoined =
+    participants && userData.id
+      ? participants.some((p) => p.userId === userData.id)
+      : false;
+
+  if (isLoading) {
+    return <div>로딩 중...</div>;
+  }
   if (isError) {
     return <div>에러 발생: {(error as Error).message}</div>;
   }
+  if (!data) {
+    return null;
+  }
 
-  if (!data) return null;
-
-  const isCreator = userData?.data?.id === gathering.createdBy;
-
+  const isCreator = userData.id === gathering.createdBy;
   const formattedDateTime = formatDateTime(data.dateTime);
   const { date: dateString, time: timeString } = formattedDateTime;
 
-  const handleJoinClick = () => {
-    if (userData?.data?.id) {
-      if (!isFull) {
-        joinGathering(gatheringId);
-        setIsJoined(true);
-      }
-    } else {
-      setIsModalOpen(true);
-    }
-  };
-
-  const handleCancelClick = () => {
-    cancelGathering(gatheringId);
-    setIsJoined(false);
-  };
-
-  const handleDeleteJoinedClick = () => {
-    deleteJoined(gatheringId);
-    setIsJoined(false);
-  };
-
-  const handleShareClick = () => {
-    navigator.clipboard.writeText(window.location.href);
-  };
-
-  const isJoining = joinStatus === "pending";
-  const isCancelling = cancelStatus === "pending";
-
-  const handleModalConfirm = () => {
-    window.location.href = "/signin";
-  };
-
   return (
     <>
       <div className="mx-auto flex max-w-screen-lg flex-col gap-8 pb-20">
-        <div className="flex flex-col items-center justify-center gap-8 md:flex-row">
+        <div className="mt-6 flex flex-col items-center justify-center gap-8 md:flex-row lg:mt-10">
           <div className="relative">
             <GatheringStatusBadge registrationEnd={gathering.registrationEnd} />
             <Image
@@ -111,7 +105,9 @@ export default function ListDetailContent({
             gatheringId={gatheringId}
           />
         </div>
+
         <ReviewSection gatheringId={gatheringId} />
+
         <FloatingBar
           isTwoButtonMode={isCreator}
           isJoined={isJoined}
@@ -119,17 +115,18 @@ export default function ListDetailContent({
           onJoin={handleJoinClick}
           onCancel={handleCancelClick}
           onDeleteJoined={handleDeleteJoinedClick}
-          isJoining={isJoining}
-          isCancelling={isCancelling}
+          isJoining={joinStatus === "pending"}
+          isCancelling={cancelStatus === "pending"}
           onShare={handleShareClick}
         />
       </div>
 
       <AlertModal
-        text="로그인이 필요해요."
-        isOpen={isModalOpen}
-        onClose={() => setIsModalOpen(false)}
-        onConfirm={handleModalConfirm}
+        text={modalConfig.text}
+        isOpen={modalConfig.isOpen}
+        hasTwoButton={modalConfig.hasTwoButton}
+        onConfirm={modalConfig.onConfirm}
+        onClose={modalConfig.onClose}
       />
     </>
   );
diff --git a/features/list-detail/hooks/useClickHandlers.ts b/features/list-detail/hooks/useClickHandlers.ts
new file mode 100644
index 0000000..daf1e6a
--- /dev/null
+++ b/features/list-detail/hooks/useClickHandlers.ts
@@ -0,0 +1,148 @@
+import { useCallback } from "react";
+import { useRouter } from "next/navigation";
+
+interface UserStore {
+  id: number | null;
+  name: string | null;
+  image: string | null;
+  email: string | null;
+  companyName: string | null;
+}
+
+interface UseClickHandlersProps {
+  userData: UserStore;
+  isFull: boolean;
+  joinStatus: string;
+  cancelStatus: string;
+  gatheringId: number;
+  joinGathering: (id: number, options: { onSuccess: () => void }) => void;
+  cancelGathering: (id: number, options: { onSuccess: () => void }) => void;
+  deleteJoined: (id: number, options: { onSuccess: () => void }) => void;
+  setIsJoined: React.Dispatch<React.SetStateAction<boolean>>;
+  setModalConfig: React.Dispatch<
+    React.SetStateAction<{
+      isOpen: boolean;
+      text: string;
+      hasTwoButton: boolean;
+      onConfirm: () => void;
+      onClose: () => void;
+    }>
+  >;
+}
+
+export default function useClickHandlers({
+  userData,
+  isFull,
+  joinStatus,
+  cancelStatus,
+  gatheringId,
+  joinGathering,
+  cancelGathering,
+  deleteJoined,
+  setIsJoined,
+  setModalConfig,
+}: UseClickHandlersProps) {
+  const router = useRouter();
+
+  const handleJoinClick = useCallback(() => {
+    if (userData?.id) {
+      if (!isFull && joinStatus !== "pending") {
+        joinGathering(gatheringId, {
+          onSuccess: () => {
+            setIsJoined(true);
+            setModalConfig({
+              isOpen: true,
+              text: "참여 완료되었습니다!",
+              hasTwoButton: false,
+              onConfirm: () =>
+                setModalConfig((prev) => ({ ...prev, isOpen: false })),
+              onClose: () =>
+                setModalConfig((prev) => ({ ...prev, isOpen: false })),
+            });
+          },
+        });
+      }
+    } else {
+      setModalConfig({
+        isOpen: true,
+        text: "로그인이 필요해요.",
+        hasTwoButton: false,
+        onConfirm: () => router.push("/signin"),
+        onClose: () => setModalConfig((prev) => ({ ...prev, isOpen: false })),
+      });
+    }
+  }, [
+    userData,
+    isFull,
+    joinStatus,
+    joinGathering,
+    gatheringId,
+    setIsJoined,
+    setModalConfig,
+    router,
+  ]);
+
+  const handleDeleteJoinedClick = useCallback(() => {
+    if (deleteJoined && cancelStatus !== "pending") {
+      setModalConfig({
+        isOpen: true,
+        text: "정말 취소하겠습니까?",
+        hasTwoButton: true,
+        onConfirm: () => {
+          setModalConfig((prev) => ({ ...prev, isOpen: false }));
+          deleteJoined(gatheringId, {
+            onSuccess: () => {
+              setIsJoined(false);
+              setModalConfig({
+                isOpen: true,
+                text: "참여 취소되었습니다!",
+                hasTwoButton: false,
+                onConfirm: () =>
+                  setModalConfig((prev) => ({ ...prev, isOpen: false })),
+                onClose: () =>
+                  setModalConfig((prev) => ({ ...prev, isOpen: false })),
+              });
+            },
+          });
+        },
+        onClose: () => setModalConfig((prev) => ({ ...prev, isOpen: false })),
+      });
+    }
+  }, [deleteJoined, cancelStatus, gatheringId, setModalConfig, setIsJoined]);
+
+  const handleCancelClick = useCallback(() => {
+    if (cancelGathering && cancelStatus !== "pending") {
+      setModalConfig({
+        isOpen: true,
+        text: "정말 취소하겠습니까?",
+        hasTwoButton: true,
+        onConfirm: () => {
+          setModalConfig((prev) => ({ ...prev, isOpen: false }));
+          cancelGathering(gatheringId, {
+            onSuccess: () => {
+              setModalConfig({
+                isOpen: true,
+                text: "모임 취소되었습니다!",
+                hasTwoButton: false,
+                onConfirm: () => router.push("/list"),
+                onClose: () => router.push("/list"),
+              });
+            },
+          });
+        },
+        onClose: () => setModalConfig((prev) => ({ ...prev, isOpen: false })),
+      });
+    }
+  }, [cancelGathering, cancelStatus, gatheringId, setModalConfig, router]);
+
+  const handleShareClick = useCallback(() => {
+    navigator.clipboard.writeText(window.location.href);
+  }, []);
+
+  return {
+    handleJoinClick,
+    handleDeleteJoinedClick,
+    handleCancelClick,
+    handleShareClick,
+  };
+}
diff --git a/features/list-detail/hooks/useModalState.ts b/features/list-detail/hooks/useModalState.ts
new file mode 100644
index 0000000..0a36563
--- /dev/null
+++ b/features/list-detail/hooks/useModalState.ts
@@ -0,0 +1,29 @@
+import { useState, useCallback } from "react";
+
+export interface ModalConfig {
+  isOpen: boolean;
+  text: string;
+  hasTwoButton: boolean;
+  onConfirm: () => void;
+  onClose: () => void;
+}
+
+const initialModalState: ModalConfig = {
+  isOpen: false,
+  text: "",
+  hasTwoButton: false,
+  onConfirm: () => {},
+  onClose: () => {},
+};
+
+export default function useModalState(
+  initialState: ModalConfig = initialModalState,
+) {
+  const [modalConfig, setModalConfig] = useState<ModalConfig>(initialState);
+
+  const closeModal = useCallback(() => {
+    setModalConfig((prev) => ({ ...prev, isOpen: false }));
+  }, []);
+
+  return { modalConfig, setModalConfig, closeModal };
+}
diff --git a/features/mypage/hooks/useGetUserInfo.ts b/features/mypage/hooks/useGetUserInfo.ts
deleted file mode 100644
index 40f7f5d..0000000
--- a/features/mypage/hooks/useGetUserInfo.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import AuthApi from "@apis/AuthApi";
-
-export default function useGetUserInfo() {
-  return useQuery({
-    queryKey: ["userInfo"],
-    queryFn: () => AuthApi.getUserInfo(),
-  });
-}


Reviews:
user: login: mxndxx
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T05:56:03Z
user: login: imsoohyeok
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T05:58:26Z
user: login: mxndxx
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T05:59:12Z
user: login: imsoohyeok
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T05:59:27Z
user: login: mxndxx
body: 수고하셨습니다!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T06:27:55Z

Review comments:
diff_hunk: @@ -27,71 +30,62 @@ export default function ListDetailContent({
   const { data, isLoading, isError, error } =
     useGetGatheringDetail(gatheringId);
   const { data: userData } = useGetUserInfo();
path: features/list-detail/components/ListDetailContent.tsx
user: login: mxndxx
body: useGetUserInfo로 사용자 정보를 가져와야하는 이유가 혹시 있을까요?_? useGetUserInfo는 프로젝트 초기에 사용자 정보가 전역적으로 관리되고 있지 않을 때, 제가 마이페이지에서 사용자 정보를 가져오기 위해 만든 훅인데 이제 전역적으로 관리되고 있어서...현재 마이페이지에서는 사용되지 않고 있는 훅입니다.
유저스토어에서 가져와도 된다면 스토어에서 가져오고  useGetUserInfo 커스텀훅은 삭제하면 될 것 같고
useGetUserInfo를 사용해야만 한다면 features/list-detail/hooks/ 으로 파일 이동해야할 것 같습니다.
created_at: 2025-03-11T05:55:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,71 +30,62 @@ export default function ListDetailContent({
   const { data, isLoading, isError, error } =
     useGetGatheringDetail(gatheringId);
   const { data: userData } = useGetUserInfo();
path: features/list-detail/components/ListDetailContent.tsx
user: login: imsoohyeok
body: 아아 이거 저번에도 말씀해주신건데 제가 놓치고 있었네요... 수정해서 다시 올리겠습니다, 죄송합니다...!
created_at: 2025-03-11T05:58:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,71 +30,62 @@ export default function ListDetailContent({
   const { data, isLoading, isError, error } =
     useGetGatheringDetail(gatheringId);
   const { data: userData } = useGetUserInfo();
path: features/list-detail/components/ListDetailContent.tsx
user: login: mxndxx
body: 수정하실때 useGetUserInfo도 같이 삭제해주시면 감사하겠습니다! 🙇‍♀️
created_at: 2025-03-11T05:59:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,71 +30,62 @@ export default function ListDetailContent({
   const { data, isLoading, isError, error } =
     useGetGatheringDetail(gatheringId);
   const { data: userData } = useGetUserInfo();
path: features/list-detail/components/ListDetailContent.tsx
user: login: imsoohyeok
body: 넵!
created_at: 2025-03-11T05:59:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-03-11T05:14:54Z
body: ## [Codecov](https://app.codecov.io/gh/Ever-Ring/EverRing/pull/139?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ever-Ring) Report
All modified and coverable lines are covered by tests :white_check_mark:

:loudspeaker: Thoughts on this report? [Let us know!](https://github.com/codecov/feedback/issues/255)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-11T05:15:27Z
body: ⚡️ Lighthouse report!
| Category | Score |
| --- | --- |
| 🔴 Performance | 36 |
| 🟢 Accessibility | 96 |
| 🟢 Best practices | 100 |
| 🟢 SEO | 100 |
🔍 Show Detail
| 🟢 First Contentful Paint | 1.3 s |
| 🔴 Largest Contentful Paint | 20.2 s |
| 🔴 Speed Index | 13.5 s |
| 🔴 Total Blocking Time | 3,470 ms |
| 🟢 Cumulative Layout Shift | 0.001 |

author_association: NONE