Repository: Ever-Ring/EverRing
Pull request #129: ♻️ 리뷰 컨테이너 리팩토링
Author: login: yejiniee
Created: 2025-03-06T06:27:30Z | Merged: 2025-03-06T06:46:29Z

## #️⃣연관된 이슈

> ex) #이슈번호, #이슈번호

## 📝작업 내용
- [x] 리뷰 컴포넌트 프로필 이미지 둥글게 수정
- [x] 뷰모델 사용해서 리뷰컨테이너 리팩토링 - 멘토님 피드백 반영
- [x] providers 폴더 루트/providers 에서 app/ 하위로 이동

### 스크린샷 (선택)

## 💬리뷰 요구사항(선택)




Diff:
diff --git a/app/(layout_dafault)/review/page.tsx b/app/(layout_dafault)/review/page.tsx
index d25d4f0..0c4990c 100644
--- a/app/(layout_dafault)/review/page.tsx
+++ b/app/(layout_dafault)/review/page.tsx
@@ -1,11 +1,10 @@
 import Image from "next/image";
-import ReviewContentContainer from "@features/review/components/ReviewContentContainer";
 import ReviewApi from "@apis/ReviewApi";
 import { GATHERING_TYPE } from "@constants/gatheringType";
 import { INITIAL_PARAMS } from "@features/review/constants/query";
+import ClientReviewContainer from "@features/review/components/ClientReviewContainer";
 
 export default async function Review() {
-  // TODO 아직 데이터가 많이 없어서 임시로 limit 작게 설정
   const params = INITIAL_PARAMS;
   const reviewDataResponse = await ReviewApi.getReviewData(params);
   const initialData = reviewDataResponse.data?.data ?? [];
@@ -34,7 +33,8 @@ export default async function Review() {
           </h2>
         </div>
       </section>
-      <ReviewContentContainer
+
+      <ClientReviewContainer
         initialData={initialData}
         totalItemCount={totalItemCount}
         initialScore={initialScore}
diff --git a/providers/ReactQueryProvider.tsx b/app/providers/ReactQueryProvider.tsx
similarity index 100%
rename from providers/ReactQueryProvider.tsx
rename to app/providers/ReactQueryProvider.tsx
diff --git a/components/common/ReviewItem.tsx b/components/common/ReviewItem.tsx
index 05c0d9b..c020432 100644
--- a/components/common/ReviewItem.tsx
+++ b/components/common/ReviewItem.tsx
@@ -20,7 +20,7 @@ export default function ReviewItem({ review }: ReviewItemProps) {
         </div>
         <div className="flex flex-row items-center gap-3">
           <p className="flex flex-row items-center gap-2">
-            <span className="relative size-6">
+            <span className="relative size-6 overflow-hidden rounded-full">
               <Image
                 alt="user-profile"
                 fill
diff --git a/components/common/ReviewItemWithImage.tsx b/components/common/ReviewItemWithImage.tsx
index f6d434d..55401b0 100644
--- a/components/common/ReviewItemWithImage.tsx
+++ b/components/common/ReviewItemWithImage.tsx
@@ -44,7 +44,7 @@ export default function ReviewItemWithImage({
           <div className="flex flex-row items-center gap-3">
             {hasUserInfo && (
               <p className="flex flex-row items-center gap-2">
-                <span className="relative size-6">
+                <span className="relative size-6 overflow-hidden rounded-full">
                   <Image
                     alt="user-profile"
                     fill
diff --git a/features/review/components/ClientReviewContainer.tsx b/features/review/components/ClientReviewContainer.tsx
new file mode 100644
index 0000000..1246375
--- /dev/null
+++ b/features/review/components/ClientReviewContainer.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { useInView } from "react-intersection-observer";
+import RatingContainer from "@features/review/components/RatingContainer";
+import FilterBar from "@features/review/components/FilterBar";
+import ReviewListwithImage from "@components/common/ReviewListWithImage";
+import Chip from "@components/common/Chip";
+import TabMenu from "@components/common/TabMenu";
+import { TABS } from "@constants/tab";
+import { chipOptions } from "@features/review/constants/review";
+import useReviewListViewModel from "@features/review/hooks/useReviewListViewModel";
+import { Review, Scores } from "@customTypes/review";
+import { memo } from "react";
+
+interface ClientReviewContainerProps {
+  initialData: Review[];
+  totalItemCount: number;
+  initialScore: Scores;
+}
+
+function ClientReviewContainer({
+  initialData,
+  totalItemCount,
+  initialScore,
+}: ClientReviewContainerProps) {
+  const {
+    filters,
+    reviews,
+    scoreData,
+    hasNextPage,
+    isError,
+    isFetchingNextPage,
+    fetchNextPage,
+    handleChangeTabIndex,
+    handleChangeChipIndex,
+    handleChangeLocation,
+    handleChangeDate,
+    handleChangeSort,
+  } = useReviewListViewModel(initialData, totalItemCount, initialScore);
+
+  const { ref: loadMoreRef } = useInView({
+    triggerOnce: false,
+    onChange: (inView) => {
+      if (inView && hasNextPage && !isFetchingNextPage) {
+        fetchNextPage();
+      }
+    },
+  });
+
+  if (isError) return <div>Error loading reviews</div>;
+
+  return (
+    <section className="flex h-full w-full flex-col gap-6">
+      <div className="sticky top-[56px] z-10 bg-gray-50 pt-6 md:top-[60px] md:pt-8">
+        <div className="flex flex-col items-start gap-3 border-b-2 border-gray-200 pb-4 lg:gap-4">
+          <TabMenu
+            hasIcon
+            tabs={TABS}
+            selectedIndex={filters.tabIndex}
+            onSelect={handleChangeTabIndex}
+          />
+          <div className="flex items-start gap-2">
+            <Chip
+              label="전체"
+              selected={filters.chipIndex === 0}
+              onClick={() => handleChangeChipIndex(0)}
+            />
+            {filters.tabIndex === 0 &&
+              chipOptions.map((chip, index) => (
+                <Chip
+                  key={chip.label}
+                  label={chip.label}
+                  selected={filters.chipIndex === index + 1}
+                  onClick={() => handleChangeChipIndex(index + 1)}
+                />
+              ))}
+          </div>
+        </div>
+      </div>
+
+      <RatingContainer scoreData={scoreData} />
+
+      <div className="flex h-full w-full flex-col items-start bg-white">
+        <div className="sticky top-[174px] z-10 w-full md:top-[190px] lg:top-[194px]">
+          <FilterBar
+            onLocationChange={handleChangeLocation}
+            onDateChange={handleChangeDate}
+            onSortChange={handleChangeSort}
+          />
+        </div>
+
+        <div className="w-full px-4 pb-6 md:px-6">
+          {reviews.length > 0 ? (
+            <>
+              <ReviewListwithImage reviewData={reviews} hasUserInfo />
+              <div ref={loadMoreRef} className="h-1" />
+              {isFetchingNextPage && (
+                <div className="flex justify-center py-4">
+                  <div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600" />
+                </div>
+              )}
+            </>
+          ) : (
+            <div className="w-full py-28 md:py-48">
+              <div className="text-center text-sm font-medium text-gray-500">
+                아직 리뷰가 없어요
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+    </section>
+  );
+}
+
+export default memo(ClientReviewContainer);
diff --git a/features/review/hooks/useReviewListViewModel.tsx b/features/review/hooks/useReviewListViewModel.tsx
new file mode 100644
index 0000000..5e2c542
--- /dev/null
+++ b/features/review/hooks/useReviewListViewModel.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { GATHERING_TYPE, GatheringType } from "@constants/gatheringType";
+import { sortMap, ASC, DESC } from "@features/review/constants/review";
+import useGetReviewData from "@features/review/hooks/useGetReviewData";
+import useGetReviewScore from "@features/review/hooks/useGetReviewScore";
+import { Review, Scores } from "@customTypes/review";
+
+interface ReviewListState {
+  tabIndex: number;
+  chipIndex: number;
+  type: GatheringType;
+  location: string;
+  date?: string;
+  sort: string;
+}
+
+export default function useReviewListViewModel(
+  initialData: Review[],
+  totalItemCount: number,
+  initialScore: Scores,
+) {
+  const [filters, setFilters] = useState<ReviewListState>({
+    tabIndex: 0,
+    chipIndex: 0,
+    type: GATHERING_TYPE.DALLAEMFIT,
+    location: "지역전체",
+    date: undefined,
+    sort: "정렬",
+  });
+
+  useEffect(() => {
+    const typeMap: Record<string, GatheringType> = {
+      "0-0": GATHERING_TYPE.DALLAEMFIT,
+      "0-1": GATHERING_TYPE.OFFICE_STRETCHING,
+      "0-2": GATHERING_TYPE.MINDFULNESS,
+      "1-0": GATHERING_TYPE.WORKATION,
+    };
+
+    const newType =
+      typeMap[`${filters.tabIndex}-${filters.chipIndex}`] ||
+      GATHERING_TYPE.DALLAEMFIT;
+    setFilters((prev) => ({ ...prev, type: newType }));
+  }, [filters.tabIndex, filters.chipIndex]);
+
+  const reviewFilter = {
+    type: filters.type,
+    location: filters.location === "지역전체" ? undefined : filters.location,
+    date: filters.date,
+    sortBy: sortMap[filters.sort] || undefined,
+    sortOrder: filters.sort === "오래된순" ? ASC : DESC,
+    limit: 10,
+  };
+
+  const {
+    data: reviews,
+    fetchNextPage,
+    hasNextPage,
+    isError,
+    isFetchingNextPage,
+  } = useGetReviewData(reviewFilter, { initialData, totalItemCount });
+
+  const { data: scoreData } = useGetReviewScore({ type: filters.type });
+
+  const handleChangeTabIndex = (index: number) => {
+    setFilters((prev) => ({ ...prev, tabIndex: index, chipIndex: 0 }));
+  };
+
+  const handleChangeChipIndex = (index: number) => {
+    setFilters((prev) => ({ ...prev, chipIndex: index }));
+  };
+
+  const handleChangeLocation = (location: string) => {
+    setFilters((prev) => ({ ...prev, location }));
+  };
+
+  const handleChangeDate = (date?: string) => {
+    setFilters((prev) => ({ ...prev, date }));
+  };
+
+  const handleChangeSort = (sort: string) => {
+    setFilters((prev) => ({ ...prev, sort }));
+  };
+
+  return {
+    filters,
+    reviews,
+    scoreData: scoreData ?? initialScore,
+    hasNextPage,
+    isError,
+    isFetchingNextPage,
+
+    fetchNextPage,
+    handleChangeTabIndex,
+    handleChangeChipIndex,
+    handleChangeLocation,
+    handleChangeDate,
+    handleChangeSort,
+  };
+}
diff --git a/tsconfig.json b/tsconfig.json
index 99773d0..702ef0e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,7 +30,7 @@
       "@customTypes/*": ["types/*"],
       "@features/*": ["features/*"],
       "@constants/*": ["constants/*"],
-      "@providers/*": ["providers/*"],
+      "@providers/*": ["app/providers/*"],
       "@utils/*": ["utils/*"],
       "@hooks/*": ["hooks/*"],
       "@apis/*": ["apis/*"],


Reviews:
user: login: imsoohyeok
body: 고생하셨습니다 👍
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T06:46:16Z
user: login: yunsung-hodoopapa
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-09T08:53:29Z

Review comments:
diff_hunk: @@ -0,0 +1,116 @@
+"use client";
+
+import { useInView } from "react-intersection-observer";
+import RatingContainer from "@features/review/components/RatingContainer";
+import FilterBar from "@features/review/components/FilterBar";
+import ReviewListwithImage from "@components/common/ReviewListWithImage";
+import Chip from "@components/common/Chip";
+import TabMenu from "@components/common/TabMenu";
+import { TABS } from "@constants/tab";
+import { chipOptions } from "@features/review/constants/review";
+import useReviewListViewModel from "@features/review/hooks/useReviewListViewModel";
+import { Review, Scores } from "@customTypes/review";
+import { memo } from "react";
+
+interface ClientReviewContainerProps {
+  initialData: Review[];
+  totalItemCount: number;
+  initialScore: Scores;
+}
+
+function ClientReviewContainer({
+  initialData,
+  totalItemCount,
+  initialScore,
+}: ClientReviewContainerProps) {
+  const {
+    filters,
+    reviews,
+    scoreData,
+    hasNextPage,
+    isError,
+    isFetchingNextPage,
+    fetchNextPage,
+    handleChangeTabIndex,
path: features/review/components/ClientReviewContainer.tsx
user: login: yunsung-hodoopapa
body: 일반적으로 뷰모델에서 리턴하는 함수들은 handle-을 사용하지 않아요

created_at: 2025-03-09T08:52:04Z
author_association: NONE
diff_hunk: @@ -0,0 +1,101 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { GATHERING_TYPE, GatheringType } from "@constants/gatheringType";
+import { sortMap, ASC, DESC } from "@features/review/constants/review";
+import useGetReviewData from "@features/review/hooks/useGetReviewData";
+import useGetReviewScore from "@features/review/hooks/useGetReviewScore";
+import { Review, Scores } from "@customTypes/review";
+
+interface ReviewListState {
+  tabIndex: number;
+  chipIndex: number;
+  type: GatheringType;
+  location: string;
+  date?: string;
+  sort: string;
+}
+
+export default function useReviewListViewModel(
+  initialData: Review[],
+  totalItemCount: number,
+  initialScore: Scores,
+) {
+  const [filters, setFilters] = useState<ReviewListState>({
+    tabIndex: 0,
+    chipIndex: 0,
+    type: GATHERING_TYPE.DALLAEMFIT,
+    location: "지역전체",
+    date: undefined,
+    sort: "정렬",
+  });
+
+  useEffect(() => {
+    const typeMap: Record<string, GatheringType> = {
+      "0-0": GATHERING_TYPE.DALLAEMFIT,
+      "0-1": GATHERING_TYPE.OFFICE_STRETCHING,
+      "0-2": GATHERING_TYPE.MINDFULNESS,
+      "1-0": GATHERING_TYPE.WORKATION,
+    };
+
+    const newType =
+      typeMap[`${filters.tabIndex}-${filters.chipIndex}`] ||
+      GATHERING_TYPE.DALLAEMFIT;
+    setFilters((prev) => ({ ...prev, type: newType }));
+  }, [filters.tabIndex, filters.chipIndex]);
+
+  const reviewFilter = {
+    type: filters.type,
+    location: filters.location === "지역전체" ? undefined : filters.location,
+    date: filters.date,
+    sortBy: sortMap[filters.sort] || undefined,
+    sortOrder: filters.sort === "오래된순" ? ASC : DESC,
+    limit: 10,
+  };
+
+  const {
+    data: reviews,
+    fetchNextPage,
+    hasNextPage,
+    isError,
+    isFetchingNextPage,
+  } = useGetReviewData(reviewFilter, { initialData, totalItemCount });
+
+  const { data: scoreData } = useGetReviewScore({ type: filters.type });
+
+  const handleChangeTabIndex = (index: number) => {
+    setFilters((prev) => ({ ...prev, tabIndex: index, chipIndex: 0 }));
path: features/review/hooks/useReviewListViewModel.tsx
user: login: yunsung-hodoopapa
body: `immer` `produce`를 사용하시면 불변성 업데이트를 조금 더 깔끔하게 처리할 수 있습니다. 참고차 알아두시면 좋아요. 
created_at: 2025-03-09T08:53:23Z
author_association: NONE

PR comments:
user: login: codecov[bot]
created_at: 2025-03-06T06:28:19Z
body: ## [Codecov](https://app.codecov.io/gh/Ever-Ring/EverRing/pull/129?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-06T06:29:13Z
body: ⚡️ Lighthouse report!
| Category | Score |
| --- | --- |
| 🔴 Performance | 36 |
| 🟢 Accessibility | 96 |
| 🟢 Best practices | 96 |
| 🟢 SEO | 100 |
🔍 Show Detail
| 🟢 First Contentful Paint | 1.1 s |
| 🔴 Largest Contentful Paint | 41.8 s |
| 🔴 Speed Index | 12.9 s |
| 🔴 Total Blocking Time | 3,370 ms |
| 🟢 Cumulative Layout Shift | 0.001 |

author_association: NONE