ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -0,0 +1,8 @@
+import "./css/reset.css";
+import "./css/common.css";
+import App from "./App";
+
+addEventListener("DOMContentLoaded", async () => {
+  const app = new App();
+  app.init();
+});

리뷰: 요 함수가 바로 앱을 구동시키는 Entry 포인트군요!
`controller`를 초기화한다? 보다는 `App`을 초기화한다는 느낌이 어떨까요?
@@ -0,0 +1,14 @@
+import { apiClient } from "./apiClient";
+import { BASE_URL } from "./constants";
+
+export const fetchPopularMovies = async ({ page = 1 }) => {
+  const param = new URLSearchParams({
+    api_key: process.env.TMDB_API_KEY,
+    language: "ko-KR",
+    page,
+  });
+
+  const response = await apiClient.get(`${BASE_URL}?${param}`);
+
+  return response.results;
+};

리뷰: 오 좋습니다!
@@ -0,0 +1,34 @@
+import { ApiError } from "./apiError";
+
+export const apiClient = {
+  get: async (url, headers = {}) => {
+    return apiClient.request("GET", url, null, headers);
+  },
+
+  request: async (method, url, body = null, headers = {}) => {
+    const options = {
+      method,
+      headers: {
+        "Content-Type": "application/json",
+        ...headers,
+      },
+      body: body && JSON.stringify(body),
+    };
+
+    try {
+      const response = await fetch(url, options);
+
+      if (!response.ok) {
+        throw new ApiError(response.status);
+      }
+
+      return await response.json();
+    } catch (error) {
+      if (error instanceof ApiError) {
+        ApiError.handle(error);
+      } else {
+        throw error;
+      }
+    }
+  },
+};

리뷰: `request()`와 `get()`의 차이는 무엇이고 나눈 이유는 무엇일까요?
@@ -0,0 +1,31 @@
+import { ERROR_MSG } from "./constants";
+
+export class ApiError extends Error {
+  constructor(message, details, status = -1) {
+    super(message);
+    this.details = details;
+    this.status = status;
+  }
+
+  static handle(error) {
+    let errMsg;
+    let errDetails;
+
+    switch (parseInt(error.message)) {
+      case 401:
+        errMsg = ERROR_MSG.INVALID_API_KEY.message;
+        errDetails = ERROR_MSG.INVALID_API_KEY.details;
+        break;
+      case 404:
+        errMsg = ERROR_MSG.INVALID_REQUEST.message;
+        errDetails = ERROR_MSG.INVALID_REQUEST.details;
+        break;
+      default:
+        errMsg = ERROR_MSG.DEFAULT.message;
+        errDetails = ERROR_MSG.DEFAULT.details;
+        break;
+    }
+
+    throw new ApiError(errMsg, errDetails, error.status);
+  }
+}

리뷰: 오 좋은 시도입니다 👍
@@ -0,0 +1,16 @@
+import { mainTitle, mainMoreButton, movieCardsList } from "./index";
+
+export const mainSection = {
+  render() {
+    const element = document.createElement("section");
+    element.classList.add("item-view");
+
+    const title = mainTitle.render();
+    const itemList = movieCardsList.render();
+    const moreButton = mainMoreButton.render();
+
+    element.append(title, itemList, moreButton);
+
+    return element;
+  },
+};

리뷰: [append()](https://developer.mozilla.org/en-US/docs/Web/API/Element/append)를 활용해보세요!
@@ -0,0 +1,46 @@
+describe("API 테스트", () => {
+  it("영화 목록 API를 호출하면 20개씩 영화를 받아온다.", () => {
+    const baseUrl = "https://api.themoviedb.org/3/movie/popular";
+    const param = new URLSearchParams({
+      api_key: Cypress.env("TMDB_API_KEY"),
+      language: "ko-KR",
+      page: 1,
+    });
+
+    cy.request(`${baseUrl}?${param}`).then((response) => {
+      expect(response.status).to.eq(200);
+      expect(response.body.results).to.have.length(20);
+    });
+  });
+});
+
+describe("영화 리뷰 웹 테스트", () => {
+  beforeEach(() => {
+    cy.visit("http://localhost:8080");
+  });
+
+  it("영화 카드는 썸네일, 별점, 제목을 갖는다.", () => {
+    cy.get(".item-card").each(($el) => {
+      cy.wrap($el).within(() => {
+        cy.get(".item-thumbnail").should("be.visible");
+        cy.get(".item-title").should("be.visible");
+        cy.get(".item-score").should("be.visible");
+      });
+    });
+  });
+
+  it("영화 목록은 한 페이지에 20개씩 나온다.", () => {
+    cy.get(".item-list li").should("have.length", 20);
+  });
+
+  it("더보기 버튼을 누르면 20개의 영화가 추가로 생성된다.", () => {
+    cy.get(".show-more").click();
+    cy.get(".item-list li").should("have.length", 40);
+  });
+
+  it("영화 목록을 불러오는 동안 스켈레톤 UI를 보여준다.", () => {
+    cy.get(".skeleton-card").should("have.length", 20);
+    cy.wait(1000);
+    cy.get(".skeleton-card").should("have.length", 0);
+  });
+});

리뷰: 오호?
`exist`로 검사하면 무엇이 다를까요?
@@ -0,0 +1,46 @@
+describe("API 테스트", () => {
+  it("영화 목록 API를 호출하면 20개씩 영화를 받아온다.", () => {
+    const baseUrl = "https://api.themoviedb.org/3/movie/popular";
+    const param = new URLSearchParams({
+      api_key: Cypress.env("TMDB_API_KEY"),
+      language: "ko-KR",
+      page: 1,
+    });
+
+    cy.request(`${baseUrl}?${param}`).then((response) => {
+      expect(response.status).to.eq(200);
+      expect(response.body.results).to.have.length(20);
+    });
+  });
+});
+
+describe("영화 리뷰 웹 테스트", () => {
+  beforeEach(() => {
+    cy.visit("http://localhost:8080");
+  });
+
+  it("영화 카드는 썸네일, 별점, 제목을 갖는다.", () => {
+    cy.get(".item-card").each(($el) => {
+      cy.wrap($el).within(() => {
+        cy.get(".item-thumbnail").should("be.visible");
+        cy.get(".item-title").should("be.visible");
+        cy.get(".item-score").should("be.visible");
+      });
+    });
+  });
+
+  it("영화 목록은 한 페이지에 20개씩 나온다.", () => {
+    cy.get(".item-list li").should("have.length", 20);
+  });
+
+  it("더보기 버튼을 누르면 20개의 영화가 추가로 생성된다.", () => {
+    cy.get(".show-more").click();
+    cy.get(".item-list li").should("have.length", 40);
+  });
+
+  it("영화 목록을 불러오는 동안 스켈레톤 UI를 보여준다.", () => {
+    cy.get(".skeleton-card").should("have.length", 20);
+    cy.wait(1000);
+    cy.get(".skeleton-card").should("have.length", 0);
+  });
+});

리뷰: 40으로 검사하는 이유는 초기 리스트 20 + 추가 더보기 20일까요?
@@ -0,0 +1,34 @@
+import { ApiError } from "./apiError";
+
+export const apiClient = {
+  get: async (url, headers = {}) => {
+    return apiClient.request("GET", url, null, headers);
+  },
+
+  request: async (method, url, body = null, headers = {}) => {
+    const options = {
+      method,
+      headers: {
+        "Content-Type": "application/json",
+        ...headers,
+      },
+      body: body && JSON.stringify(body),
+    };
+
+    try {
+      const response = await fetch(url, options);
+
+      if (!response.ok) {
+        throw new ApiError(response.status);
+      }
+
+      return await response.json();
+    } catch (error) {
+      if (error instanceof ApiError) {
+        ApiError.handle(error);
+      } else {
+        throw error;
+      }
+    }
+  },
+};

리뷰: `request()`는 매개변수로 받는 method를 통해 API 요청을 보내는 함수이고, `get()`은 해당 `request()` 함수에 `GET`요청을 보내는 함수입니다.

이번 과제에서 GET 요청만 존재하여 추가하진 않았지만 추후에 다른 메서드 요청을 추가할 수 있게 작성하였습니다!
@@ -0,0 +1,46 @@
+describe("API 테스트", () => {
+  it("영화 목록 API를 호출하면 20개씩 영화를 받아온다.", () => {
+    const baseUrl = "https://api.themoviedb.org/3/movie/popular";
+    const param = new URLSearchParams({
+      api_key: Cypress.env("TMDB_API_KEY"),
+      language: "ko-KR",
+      page: 1,
+    });
+
+    cy.request(`${baseUrl}?${param}`).then((response) => {
+      expect(response.status).to.eq(200);
+      expect(response.body.results).to.have.length(20);
+    });
+  });
+});
+
+describe("영화 리뷰 웹 테스트", () => {
+  beforeEach(() => {
+    cy.visit("http://localhost:8080");
+  });
+
+  it("영화 카드는 썸네일, 별점, 제목을 갖는다.", () => {
+    cy.get(".item-card").each(($el) => {
+      cy.wrap($el).within(() => {
+        cy.get(".item-thumbnail").should("be.visible");
+        cy.get(".item-title").should("be.visible");
+        cy.get(".item-score").should("be.visible");
+      });
+    });
+  });
+
+  it("영화 목록은 한 페이지에 20개씩 나온다.", () => {
+    cy.get(".item-list li").should("have.length", 20);
+  });
+
+  it("더보기 버튼을 누르면 20개의 영화가 추가로 생성된다.", () => {
+    cy.get(".show-more").click();
+    cy.get(".item-list li").should("have.length", 40);
+  });
+
+  it("영화 목록을 불러오는 동안 스켈레톤 UI를 보여준다.", () => {
+    cy.get(".skeleton-card").should("have.length", 20);
+    cy.wait(1000);
+    cy.get(".skeleton-card").should("have.length", 0);
+  });
+});

리뷰: 생성된 `movieCard`에는 썸네일, 별점, 제목 element가 `존재`한다는 의미에서 사용한 것 같습니다!

다시 생각해보니, 해당 element가 존재하지만 보여지지 않거나 어떤 css issue가 생길 수도 있다는 생각에 `be.visible`로 대체하였습니다.
@@ -0,0 +1,46 @@
+describe("API 테스트", () => {
+  it("영화 목록 API를 호출하면 20개씩 영화를 받아온다.", () => {
+    const baseUrl = "https://api.themoviedb.org/3/movie/popular";
+    const param = new URLSearchParams({
+      api_key: Cypress.env("TMDB_API_KEY"),
+      language: "ko-KR",
+      page: 1,
+    });
+
+    cy.request(`${baseUrl}?${param}`).then((response) => {
+      expect(response.status).to.eq(200);
+      expect(response.body.results).to.have.length(20);
+    });
+  });
+});
+
+describe("영화 리뷰 웹 테스트", () => {
+  beforeEach(() => {
+    cy.visit("http://localhost:8080");
+  });
+
+  it("영화 카드는 썸네일, 별점, 제목을 갖는다.", () => {
+    cy.get(".item-card").each(($el) => {
+      cy.wrap($el).within(() => {
+        cy.get(".item-thumbnail").should("be.visible");
+        cy.get(".item-title").should("be.visible");
+        cy.get(".item-score").should("be.visible");
+      });
+    });
+  });
+
+  it("영화 목록은 한 페이지에 20개씩 나온다.", () => {
+    cy.get(".item-list li").should("have.length", 20);
+  });
+
+  it("더보기 버튼을 누르면 20개의 영화가 추가로 생성된다.", () => {
+    cy.get(".show-more").click();
+    cy.get(".item-list li").should("have.length", 40);
+  });
+
+  it("영화 목록을 불러오는 동안 스켈레톤 UI를 보여준다.", () => {
+    cy.get(".skeleton-card").should("have.length", 20);
+    cy.wait(1000);
+    cy.get(".skeleton-card").should("have.length", 0);
+  });
+});

리뷰: 네 맞습니다! 이전 테스트에서 20개가 렌더링이 되었고 버튼을 클릭한 후에, 20개가 더 추가되어 40개를 검사하게 하였습니다. 

새롭게 렌더링된 20개에 대해서 테스트를 진행하는 것이 더 올바른 검사일까요?

+) 추가로, 단위테스트에서는 샘플 값을 통해 도출되는 값을 검사하는 과정이 테스트의 핵심이었다고 생각되는데, 
이번에 e2e에서는 값을 이용하여 검사하는 방향보다 렌더링되는 요소의 개수나 생성 여부를 중심적으로 코드를 작성한 것 같습니다.

그런 과정에서 단위 테스트는 값 중심, e2e는 전체적인 DOM 렌더링 중심 이렇게 자연스레 생각이 이어지게 되었는데 이게 맞는 생각일까 의구심이 들긴 합니다,,!
@@ -0,0 +1,40 @@
+package org.sopt.androidseminar1
+
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import android.os.Bundle
+import android.widget.Toast
+import org.sopt.androidseminar1.databinding.SignInBinding
+
+class SignInActivity: AppCompatActivity() {
+    private lateinit var  binding: SignInBinding // 고정적인 Class 값은 아니다.
+                                                        // activity_main -> ActivityMain
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        binding = SignInBinding.inflate(layoutInflater) // layoutInflater : xml을 불러주는 정도
+        setContentView(binding.root) // setContentView : xml을 그려주는 함수
+
+
+        // intent 변경
+        val goSignup: Intent = Intent(this, SignUpActivity::class.java)
+        val goHomeActivity: Intent = Intent(this, HomeActivity::class.java )
+
+        // 로그인 버튼
+        binding.btnLogin.setOnClickListener {
+            if (binding.etId.text.toString().isEmpty() || binding.etPw.text.toString().isEmpty()){
+                Toast.makeText(this, "로그인 실패", Toast.LENGTH_SHORT).show()
+            }
+            else {
+                startActivity(goHomeActivity)
+            }
+        }
+
+        // 회원가입
+        binding.btnSignup.setOnClickListener{
+            startActivity(goSignup)
+        }
+
+        setContentView(binding.root)
+    }
+}
\ No newline at end of file

리뷰: 코틀린에서는 타입 추론 기능을 지원해주기때문에
```suggestion
        val goSignup = Intent(this, SignUpActivity::class.java)
        val goHomeActivity = Intent(this, HomeActivity::class.java )
```
이렇게만 작성하셔도 괜찮습니당~:)
@@ -0,0 +1,40 @@
+package org.sopt.androidseminar1
+
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import android.os.Bundle
+import android.widget.Toast
+import org.sopt.androidseminar1.databinding.SignInBinding
+
+class SignInActivity: AppCompatActivity() {
+    private lateinit var  binding: SignInBinding // 고정적인 Class 값은 아니다.
+                                                        // activity_main -> ActivityMain
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        binding = SignInBinding.inflate(layoutInflater) // layoutInflater : xml을 불러주는 정도
+        setContentView(binding.root) // setContentView : xml을 그려주는 함수
+
+
+        // intent 변경
+        val goSignup: Intent = Intent(this, SignUpActivity::class.java)
+        val goHomeActivity: Intent = Intent(this, HomeActivity::class.java )
+
+        // 로그인 버튼
+        binding.btnLogin.setOnClickListener {
+            if (binding.etId.text.toString().isEmpty() || binding.etPw.text.toString().isEmpty()){
+                Toast.makeText(this, "로그인 실패", Toast.LENGTH_SHORT).show()
+            }
+            else {
+                startActivity(goHomeActivity)
+            }
+        }
+
+        // 회원가입
+        binding.btnSignup.setOnClickListener{
+            startActivity(goSignup)
+        }
+
+        setContentView(binding.root)
+    }
+}
\ No newline at end of file

리뷰: 여기서 toString()의 경우 빼주셔도 괜찮습니다~!!
@@ -0,0 +1,40 @@
+package org.sopt.androidseminar1
+
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import android.os.Bundle
+import android.widget.Toast
+import org.sopt.androidseminar1.databinding.SignInBinding
+
+class SignInActivity: AppCompatActivity() {
+    private lateinit var  binding: SignInBinding // 고정적인 Class 값은 아니다.
+                                                        // activity_main -> ActivityMain
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        binding = SignInBinding.inflate(layoutInflater) // layoutInflater : xml을 불러주는 정도
+        setContentView(binding.root) // setContentView : xml을 그려주는 함수
+
+
+        // intent 변경
+        val goSignup: Intent = Intent(this, SignUpActivity::class.java)
+        val goHomeActivity: Intent = Intent(this, HomeActivity::class.java )
+
+        // 로그인 버튼
+        binding.btnLogin.setOnClickListener {
+            if (binding.etId.text.toString().isEmpty() || binding.etPw.text.toString().isEmpty()){
+                Toast.makeText(this, "로그인 실패", Toast.LENGTH_SHORT).show()
+            }
+            else {
+                startActivity(goHomeActivity)
+            }
+        }
+
+        // 회원가입
+        binding.btnSignup.setOnClickListener{
+            startActivity(goSignup)
+        }
+
+        setContentView(binding.root)
+    }
+}
\ No newline at end of file

리뷰: 현재 setContentView 함수가 두번 호출되고 있는데 하나는 지워주셔도 괜찮을 것 같아요~!!
@@ -0,0 +1,40 @@
+package org.sopt.androidseminar1
+
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import android.os.Bundle
+import android.widget.Toast
+import org.sopt.androidseminar1.databinding.SignInBinding
+
+class SignInActivity: AppCompatActivity() {
+    private lateinit var  binding: SignInBinding // 고정적인 Class 값은 아니다.
+                                                        // activity_main -> ActivityMain
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        binding = SignInBinding.inflate(layoutInflater) // layoutInflater : xml을 불러주는 정도
+        setContentView(binding.root) // setContentView : xml을 그려주는 함수
+
+
+        // intent 변경
+        val goSignup: Intent = Intent(this, SignUpActivity::class.java)
+        val goHomeActivity: Intent = Intent(this, HomeActivity::class.java )
+
+        // 로그인 버튼
+        binding.btnLogin.setOnClickListener {
+            if (binding.etId.text.toString().isEmpty() || binding.etPw.text.toString().isEmpty()){
+                Toast.makeText(this, "로그인 실패", Toast.LENGTH_SHORT).show()
+            }
+            else {
+                startActivity(goHomeActivity)
+            }
+        }
+
+        // 회원가입
+        binding.btnSignup.setOnClickListener{
+            startActivity(goSignup)
+        }
+
+        setContentView(binding.root)
+    }
+}
\ No newline at end of file

리뷰: 모든 로직을 다 onCreate에 넣기보다는 가독성, 재사용성 측면을 고려해서 함수화를 해주시는 게 좋을 것 같아요~!!
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <corners android:radius="5dip"/>
+
+    <stroke android:width="2dp"
+        android:color="#F658A6"/>
+    <solid android:color="#F658A6"/>
+    <corners
+        android:topLeftRadius="12dp"
+        android:topRightRadius="12dp"
+        android:bottomLeftRadius="12dp"
+        android:bottomRightRadius="12dp"/>
+</shape>
\ No newline at end of file

리뷰: dip는 dp와 같은 개념입니다! 수치를 dp로 바꿔주는게 좋을 것 같아요~!!!
@@ -1,11 +1,29 @@
 import { useState } from "react";
+import Header from "./components/Header/Header";
+import Product from "./pages/Product";
+import GlobalStyles from "./styles/Global.style";
+import Toast from "./components/common/Toast/Toast";
+import { QuantityContext } from "./store/QuantityContext";
+import { createPortal } from "react-dom";
 
 function App() {
-  const [count, setCount] = useState(0);
-
+  const [quantity, setQuantity] = useState(0);
+  const [showToast, setShowToast] = useState(false);
+  const [toastMessage, setToastMessage] = useState("");
+  const handleError = (message: string) => {
+    setToastMessage(message);
+    setShowToast(true);
+    setTimeout(() => setShowToast(false), 3000);
+  };
   return (
     <>
-      <h1>React Shopping Products</h1>
+      <GlobalStyles />
+      {showToast &&
+        createPortal(<Toast message={toastMessage} />, document.body)}
+      <QuantityContext.Provider value={{ quantity, setQuantity }}>
+        <Header />
+        <Product onError={handleError} />
+      </QuantityContext.Provider>
     </>
   );
 }

리뷰: 여기 이 `showToast` 상태를 토스트 컴포넌트가 책임을 가질 수 있지 않을까 싶었는데, 그렇다면 createPortal을 사용할 수 없게 되나요..? createPortal을 사용해보지 않아서 잘 모르겠지만, 미리 열어둘 수 있다면 그쪽으로 상태를 옮겨봐도 좋을 것 같아요!
@@ -0,0 +1,52 @@
+import { generateBasicToken } from "../util/auth";
+import { CART_ITEMS_ENDPOINT } from "./config";
+
+const USER_PASSWORD = import.meta.env.VITE_USER_PASSWORD;
+const USER_ID = import.meta.env.VITE_USER_ID;
+const token = generateBasicToken(USER_ID, USER_PASSWORD);
+
+export async function requestFetchCartItemList() {
+  const response = await fetch(`${CART_ITEMS_ENDPOINT}?size=2000`, {
+    method: "GET",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: token,
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error("Failed to fetch cart item list");
+  }
+
+  const data = await response.json();
+  return data;
+}
+
+export async function requestAddCartItem(productId: number, quantity: number) {
+  const response = await fetch(`${CART_ITEMS_ENDPOINT}`, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: token,
+    },
+    body: JSON.stringify({ productId, quantity }),
+  });
+
+  if (!response.ok) {
+    throw new Error("Failed to add cart item");
+  }
+}
+
+export async function requestDeleteCartItem(cartItemId: number | undefined) {
+  const response = await fetch(`${CART_ITEMS_ENDPOINT}/${cartItemId}`, {
+    method: "DELETE",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: token,
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error("Failed to delete cart item");
+  }
+}

리뷰: 필수는 아니지만 반복되는 fetch 로직들은 추상화해보셔도 좋을 것 같아요!ㅎㅎ
@@ -0,0 +1,52 @@
+import { generateBasicToken } from "../util/auth";
+import { CART_ITEMS_ENDPOINT } from "./config";
+
+const USER_PASSWORD = import.meta.env.VITE_USER_PASSWORD;
+const USER_ID = import.meta.env.VITE_USER_ID;
+const token = generateBasicToken(USER_ID, USER_PASSWORD);
+
+export async function requestFetchCartItemList() {
+  const response = await fetch(`${CART_ITEMS_ENDPOINT}?size=2000`, {
+    method: "GET",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: token,
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error("Failed to fetch cart item list");
+  }
+
+  const data = await response.json();
+  return data;
+}
+
+export async function requestAddCartItem(productId: number, quantity: number) {
+  const response = await fetch(`${CART_ITEMS_ENDPOINT}`, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: token,
+    },
+    body: JSON.stringify({ productId, quantity }),
+  });
+
+  if (!response.ok) {
+    throw new Error("Failed to add cart item");
+  }
+}
+
+export async function requestDeleteCartItem(cartItemId: number | undefined) {
+  const response = await fetch(`${CART_ITEMS_ENDPOINT}/${cartItemId}`, {
+    method: "DELETE",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: token,
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error("Failed to delete cart item");
+  }
+}

리뷰: chat GPT한테 '이 코드 추상화 해봐'하면 기가막히게 해주더라구요
@@ -0,0 +1,10 @@
+import { generateBasicToken } from "../util/auth";
+
+const API_URL = import.meta.env.VITE_API_URL;
+
+export const PRODUCTS_ENDPOINT = `${API_URL}/products`;
+export const CART_ITEMS_ENDPOINT = `${API_URL}/cart-items`;
+
+export const USER_PASSWORD = import.meta.env.VITE_USER_PASSWORD;
+export const USER_ID = import.meta.env.VITE_USER_ID;
+export const token = generateBasicToken(USER_ID, USER_PASSWORD);

리뷰: api요청에 필요한 변수들이 잘 모여있어 관리하기 편할 것 같네요! 👍
@@ -0,0 +1,87 @@
+import { useContext, useEffect, useRef } from "react";
+import { QuantityContext } from "../../store/QuantityContext";
+import useProductList from "../../hooks/useProductList";
+import useCartItemList from "../../hooks/useCartItemList";
+import ProductItem from "../ProductItem/ProductItem";
+import * as S from "./ProductItemList.style";
+import { Category } from "../../interfaces/Product";
+import { Sorting } from "../../interfaces/Sorting";
+import useIntersectionObserver from "../../hooks/useIntersectionObserver";
+import Spinner from "../common/Spinner/Spinner";
+
+interface ProductItemListProp {
+  category: Category;
+  sortOption: Sorting;
+  onError: (error: string) => void;
+}
+
+function ProductItemList({
+  category,
+  sortOption,
+  onError,
+}: ProductItemListProp) {
+  const {
+    productList,
+    productListError,
+    productListLoading,
+    page,
+    fetchNextPage,
+    isLastPage,
+    setPage,
+  } = useProductList({
+    category,
+    sortOption,
+  });
+  const { cartItemList, isInCart, toggleCartItem, cartItemListError } =
+    useCartItemList();
+  const target = useRef(null);
+  const [observe, unobserve] = useIntersectionObserver(() => {
+    fetchNextPage();
+  });
+
+  useEffect(() => {
+    setPage(0);
+  }, [category, sortOption]);
+
+  useEffect(() => {
+    if (page === -1 || target.current === null) return;
+    observe(target.current);
+
+    const N = productList.length;
+
+    if (0 === N || isLastPage) {
+      unobserve(target.current);
+    }
+  }, [productList, page, observe, unobserve]);
+  const quantityContext = useContext(QuantityContext);
+  const setQuantity = quantityContext ? quantityContext.setQuantity : () => {};
+  setQuantity(cartItemList.length);
+
+  if (productListError) {
+    onError("상품 목록 조회 실패");
+  }
+  if (cartItemListError) {
+    onError("장바구니 목록 조회 실패");
+  }
+
+  return (
+    <>
+      <S.ProductList>
+        {productList.map((product, idx) => {
+          return (
+            <ProductItem
+              key={`${idx}_${product.id}`}
+              product={product}
+              isInCart={isInCart(product.id)}
+              toggleCartItem={() => toggleCartItem(product)}
+            />
+          );
+        })}
+      </S.ProductList>
+      <div ref={target} style={{ height: "1px" }}></div>
+      {productListLoading && <Spinner />}
+    </>
+  );
+}
+
+export default ProductItemList;

리뷰: 페이지네이션 관련 로직도 커스텀 훅으로 분리하면 좋을 것 같아요!