product
가져오는 api
개발api.js
로 진행된 내용을 index.js
와 각각의 api
를 가져오는 파트로 분리하여 개발redux
+ thunks
를 이용해 상태를 관리할 예정layouts
디렉토리 생성GNB
content
Footer
등으로 나뉘어진 레이아웃을 모아 각 페이지를 구성하기 위해 마지막으로 레이아웃을 구성하는 디렉토리Intersecting
을 통한 스크롤 감지로 데이터를 더 불러오기state
를 통해 더 이상 데이터를 불러오지 않도록 해야함.lodash
라이브러리 레포지토리 소스를 확인해서 공부를 해보는 것 중요함.useSWR
과 react-query
에 대한 공부와 사용 준비alt
태그 사용LoginForm.jsx
import Container from "../atoms/Container";
import InputGroup from "../molecules/InputGroup";
import Button from "../atoms/Button";
import useInput from "../../hooks/useInput";
import Title from "../atoms/Title";
import { useDispatch, useSelector } from "react-redux";
import { loginRequest, setLogin } from "../../store/slices/userSlice";
import { useNavigate } from "react-router-dom";
import { emailExp, passwordExp } from "../../exp";
const LoginForm = () => {
const navigator = useNavigate();
function gohome(url) {
navigator(url);
}
const dispatch = useDispatch();
const { value, handleOnChange } = useInput({
email: "",
password: "",
});
const email = useSelector((state) => state.email);
const error = useSelector((state) => state.error);
return (
<Container className="leading-6 font-sans text-gray-700 m-0 p-0 h-full text-sm">
<Container className="leading-6 font-sans text-gray-700 m-0 p-0 inline-block overflow-x-hidden w-full text-xs align-middle">
<Container className="leading-6 font-sans text-gray-700 text-xs m-0 p-0 pt-12">
<h1 className="leading-6 font-sans text-gray-700 m-0 p-0 block">
kakao
</h1>
</Container>
<article className="text-base leading-6 font-sans text-gray-700 w-580 h-full mx-auto my-40 mb-42 px-0 py-0 border border-gray-300 text-12 box-border">
<Container className="text-xs leading-6 font-sans text-gray-700 m-0 relative h-full py-55 pb-50 box-border overflow-hidden">
<Container className="text-xs leading-6 font-sans text-gray-700 m-0 relative h-full py-55 pb-50 box-border overflow-hidden">
<Container className="mb-4 text-lg">
<InputGroup
className="appearance-none bg-transparent border-0 text-base leading-6 text-gray-900 p-10 pb-8 w-full min-h-45px box-border focus:outline-none caret-black"
id="email"
type="email"
placeholder="이메일을 입력해주세요"
//label="이메일"
name="email"
value={value.email}
onChange={handleOnChange}
/>
</Container>
<Container className="mb-4 text-lg">
<InputGroup
className="appearance-none bg-transparent border-0 text-sm leading-6 text-gray-900 p-12 pb-8 w-full min-h-45px box-border focus:outline-none caret-black tracking-wider font-small-caps"
id="password"
type="password"
name="password"
placeholder="********"
//label="비밀번호"
value={value.password}
onChange={handleOnChange}
/>
</Container>
<Container className="text-xs leading-6 font-sans text-gray-700 m-0 p-0 pt-40 text-center">
<Button
className="appearance-none bg-yellow-400 text-base leading-[51px] font-normal text-gray-900 p-0 m-0 w-full h-50px rounded-md cursor-pointer"
onClick={() => {
new Promise((resolve, reject) => {
//프론트 엔드에서 진행하는 유효성 검사 프로미스
if (!emailExp(value.email))
reject("이메일 형식이 올바르지 않습니다.");
else if (!passwordExp(value.password))
reject("비밀번호 형식이 올바르지 않습니다.");
resolve(1);
})
.then(() => {
//유효성 검사 완료 후 api 요청
dispatch(
loginRequest({
email: value.email,
password: value.password,
})
).then((res) => {
if (res.payload.success) {
//로그인 성공시
dispatch(setLogin(true)); //로그인이 됐다면 바로 (로그아웃)으로 버튼이 뜰 수 있게 상태 설정
gohome("/"); //성공 시 홈페이지 이동
}
});
})
.catch((error) => alert(error));
}}
>
Login
</Button>
<span className="font-sans text-gray-700 text-center m-0 relative inline-block p-15px font-size-0 line-height-0">
or
</span>
</Container>
</Container>
</Container>
</article>
</Container>
</Container>
);
};
export default LoginForm;
MainPage.jsx
를 바로 쓰지 않고 Gnb.jsx
와 MainLayout.jsx
로 나누어 현재 로그인 페이지를 렌더링 하였다.//Gnb.jsx
import { useNavigate } from "react-router-dom";
import Container from "../atoms/Container";
import { useSelector } from "react-redux";
import Button from "../atoms/Button";
import { setLogin } from "../../store/slices/userSlice";
import { deleteCookie } from "../../store/cookies";
const GNB = ({ children }) => {
const movePage = useNavigate();
const loginedUser = useSelector((state) => state.user.logined);
function gohome(url) {
movePage(url);
}
return (
<Container className="pc_head inner_head fixed left-0 right-0 top-0 z-11000 border-b-2 border-gray-300 bg-white">
<Container className="text-base leading-6 font-sans text-gray-700 mx-0 px-8"></Container>
<Container className="text-base leading-6 font-sans text-gray-700 m-0 relative ml-auto p-14 pr-13 pt-0"></Container>
<Container className="text-base leading-6 font-sans text-gray-700 p-0 flex w-1280 h-79 mx-auto">
<h1 class="block text-2xl my-4 font-bold">
<img
alt="톡쇼핑하기"
className="overflow-clip-margin-content overflow-clip overflow-x-auto overflow-y-auto"
src="https://st.kakaocdn.net/commerce_ui/front-talkstore/real/20230707/130532/assets/images/pc/pc_logo.png"
/>
</h1>
<Container className="text-base leading-6 font-sans text-gray-700 relative py-3 px-6">
<Button
className="text-base leading-7 font-sans text-black no-underline block py-3 px-0 font-semibold"
onClick={() => {
let url = "/login";
if (loginedUser) {
setLogin(false); //로그아웃 상태로 만듦
deleteCookie("token"); //유효시간 관리하는 토큰 쿠키 삭제
alert("logout");
url = "/";
}
gohome(url);
}}
>
{loginedUser ? "로그아웃" : "로그인"}
</Button>
<Button
className="text-base leading-7 font-sans text-black no-underline block py-3 px-0 font-semibold"
onClick={() => {
gohome("/signup");
}}
>
회원가입 이동
</Button>
</Container>
</Container>
</Container>
);
};
export default GNB;
services
디렉토리의 api.js
분리하기instatnce
가져오는 index.js
user login/register
담당하는 user.js
product.js
로 분리했다.Product
를 가져오기 위한 jsx 개발atoms/card.jsx
import { Link } from "react-router-dom";
import "../../styles/atoms/Card.css";
const Card = ({ to, children }) => {
return (
<Link className="card" to={to}>
{children}
</Link>
);
};
export default Card;
molecules/ProductCard.jsx
import { comma } from "../../utils/convert";
import Card from "../atoms/Card";
const ProductCard = ({ product }) => {
return (
<Card to={`/product/${product.id}`}>
<img src={product.image} alt={product.productName} />
<h3>{product.productName}</h3>
<p>{comma(product.price)}</p>
</Card>
);
};
export default ProductCard;
organisms/ProductGrid.js
import ProductCard from "../molecules/ProductCard";
const ProductGrid = ({ products }) => {
return (
<div className="product-grid">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
};
export default ProductGrid;
templates/ProductSection.jsx
import { useEffect, useRef, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getProducts } from "../../store/slices/productSlice";
import ProductGrid from "../organisms/ProductGrid";
import Container from "../atoms/Container";
const ProductSection = () => {
const [page, setPage] = useState(0);
const bottomObserver = useRef(null);
const dispatch = useDispatch();
const { products, loading, error, isEnd } = useSelector(
(state) => state.products
);
const io = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setPage((page) => page + 1);
}
});
},
{
threshold: 1,
}
);
useEffect(() => {
io.observe(bottomObserver.current);
}, []);
useEffect(() => {
dispatch(getProducts(page));
}, [dispatch, page]);
return (
<Container className="product-section">
{loading && <p>Loading...</p>}
{error && <p>Error</p>}
<ProductGrid products={products} />
<div ref={bottomObserver}></div>
</Container>
);
};
export default ProductSection;
lodash
배열과 관련된 메서드 처리를 해준다.
.uniqBy
는 매개변수로 받은 배열을 2번째 인자로 받은 변수를 기준으로 유니크한 값만 배열형태로 리턴
커서를 사용하지 않고 무한 스크롤에 중복을 제거하기 위한 방법 (코드 실행 중간에 백엔드에 데이터가 추가 됐을 때 제대로 못가져오는 상황이 있을 수 있다.)
state.products = _.uniqBy(
[...state.products, ...action.payload.response],
"id"
);
product
가져오기 성공useEffect
를 통해 isEnd
가 true가 되면 서버에 요청을 하지 않도록 막고 무한 스크롤이 제대로 동작하는 것을 확인했다.ProductDetailPage
만들기
/product/${id}
형식으로 요청이 들어오기 때문에 상세 페이지 구현 후 App.js
에서 각 id로 라우팅 해줘야한다.import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { useParams } from "react-router-dom";
import { getDetail } from "../store/slices/detailSlice";
const ProductDetailPage = () => {
const { id } = useParams(); //string
const dispatch = useDispatch();
useEffect(() => {
dispatch(getDetail(id));
}, [dispatch, id]);
return (
<div>
<h1>Product Detail Page</h1>
</div>
);
};
export default ProductDetailPage;
loader
만들기loading=true
일 경우 Loader.jsx
를 통해 사용자에게 로딩 중임을 알린다.loader.jsx
를 만들고 해당 product/{id}
가 들어왔을 때 {loading && {Loader />
와 같이 사용해 로딩 중이라면 로더 컴포넌트를 렌더링 하는 방식으로 사용했다.react-thunks
에서 구조 분해 할당을 통해 useSelector
를 불러오게 되면 모두 리렌더링이 되기 때문에 Bad Case로 분류된다.Slice
제거, reducer
제거 후 get 함수 이후 직접 데이터를 가져온다.index.js
에서 QueryClient
로 App
을 감싸주고 Client 지정을 해준다.const {
data: detail,
error,
isLoading,
} = useQuery(`product/${id}`, () => getProductById(id));
//와 같이 요청 함수와 라우트 경로를 지정해서 정보를 가져온다.
//여러 개의 요청을 가져올 땐 useQueries를 사용해 동기적으로 가져올 수 있다.
ProductCard
ProductGrid
를 수정하여 테스트 이미지를 사진과 같은 크기로 10개씩 loading중일 때 불러오도록 만들었다.react-global-loader
를 설치해서 사용하기로 했다.HomePage.jsx
import { useEffect } from "react";
import ProductSection from "../components/templates/ProductSection";
import { loader } from "react-global-loader";
import { useSelector } from "react-redux";
const HomePage = () => {
const loading = useSelector((state) => state.product.loading);
const showLoader = () => {
loader.show();
};
const hideLoader = () => {
loader.hide();
};
useEffect(() => {
if (!loading) hideLoader();
else showLoader();
}, [loading]);
return <ProductSection />;
};
export default HomePage;
401
에러를 제외하고는 api 호출조차 실행시키지 않지만 일단 함수를 만들어 관리하기로 했다.statuscatch.js
export const checkStatus = (error) => {
const errorApiType = `error : ${error.type}`;
const statusCode = error.payload.error.status;
console.log(errorApiType);
switch (statusCode) {
case 101:
console.log("switching Protocol");
break;
case 102:
console.log("Processing (WedDAV) : 요청 수신 및 처리 중");
break;
case 300:
console.log(
"Mulitple Choice: request에 대해 하나 이상의 응답이 가능, 하나를 선택해야 합니다."
);
break;
case 301:
console.log(
"301(Moved Permantly) : 요청한 리소스의 URI가 변경되었습니다."
);
break;
case 302:
console.log(
"302(Found) : 요청한 리소스의 URI가 일시적으로 변경되었을 때를 의미합니다."
);
break;
case 303:
console.log(
"303(See Other) : 클라이언트가 요청한 리소스를 다른 URI에서 GET 요청을 통해 얻어야 합니다."
);
break;
case 304:
console.log(
"304(Not modified) : 마지막 요청 이후 요청한 페이지가 수정되지 않았습니다. 서버가 이 응답을 표시하면 페이지의 콘텐츠를 표시하지 않습니다."
);
break;
case 305:
console.log(
"305(Use Proxy) : 요청한 응답은 반드시 프록시를 통해 접속해야 함을 알려줍니다."
);
break;
case 308:
console.log("3308(Permanent Redirect)");
break;
case 400:
console.log(
"400(Bad Request) : 클라이언트의 request가 유효하지 않은 상태를 의미합니다."
);
break;
case 401:
console.log(
"401(Unauthorized) : 클라이언트가 권한이 없어 작업을 진행하지 못합니다. 이 요청은 인증이 필요합니다. 보통 서버는 로그인이 필요한 페이지에 대해 이 요청을 제공할 수 있습니다."
);
break;
case 403:
console.log(
"403(Forbidden) : 서버가 요청을 거부할 때 입니다. 예를 들어 사용자가 리소스에 대한 필요 권한을 가지고 있지 않을 때를 의미합니다."
);
break;
case 404:
console.log(
"404(Not Found) : 서버가 요청한 페이지(resource)를 찾지 못했을 때입니다. 서버에 존재하지 않는 페이지에 대한 요구를 할 때 다음과 같은 status code가 반환됩니다."
);
break;
case 405:
console.log(
"405(Method Not Allowed) : 클라이언트의 요청이 허용되지 않은 메서드인 경우입니다. (예를 들어 POST 방식으로만 request가 가능한데 이를 지키지 않고 GET으로 보냈을 때)"
);
break;
case 409:
console.log(
"409(Conflict) : 서버가 요청을 수행하는 중에 충돌이 발생했을 때 입니다."
);
break;
case 414:
console.log(
"414 : 요청하는 URL(일반적으로는 URL)이 너무 길었을 때의 status code 입니다."
);
break;
case 419:
console.log(
"419(Too Many Requests) : 사용자가 일정 시간 동안 너무 많은 request를 보냈을 때 입니다."
);
break;
case 500:
console.log(
"500(Internal Server Error) : 서버에 오류가 발생하여 요청을 수행할 수 없을 경우"
);
break;
case 501:
console.log(
"501(Not Implemented) : 서버에 해당 요청을 수행할 수 있는 기능이 없는 경우(서버가 요청 메소드를 인식하지 못하는 경우입니다.)"
);
break;
case 502:
console.log(
"502(Bad Gateway) : 서버가 게이트웨이나 프록시 역할을 하고 있는 업스트림 서버에서 잘못된 응답을 받았을 경우"
);
break;
case 504:
console.log(
"504(Gateway Timeout) : 서버가 게이트웨이나 프록시 역할을 하고 있거나 또는 업스트림 서버에서 제때 요청을 받지 못한 경우."
);
break;
case 511:
console.log(
"511(Network Authentication Required) : 네트워크 인증이 필요한 경우입니다."
);
break;
default:
break;
}
};
//login, Register에 적용했다.
//200번대의 error라고 보아야 하는 응답이라고 할 수 있을지 모르겠어서 200이 아닌 경우를 모두 처리해줘야하는 것인지 모르겠다.
ProductDetailPage
에서 로딩이 끝난 후 Detail
이 true라면 detail 객체를 넘겨 각 상품을 표시하는 사이트를 만들어야 한다.px
을 제대로 분할하지 않아서 손봐야 할 것 같다.import Container from "../atoms/Container";
import Photo from "../atoms/Photo";
const ProductDetail = (detail) => {
console.log(detail);
console.log(detail.detail.image, detail.detail.productName);
return (
<Container className="text-base leading-6 font-sans text-gray-700 text-sm lg:text-base font-HelveticaNeue AppleSDGothicNeo-Regular tahoma Malgun Gothic b9d1c740 \ace0\b515 dotum \b3cb\c6c0 sans-serif text-black">
<h3 className="font-HelveticaNeue AppleSDGothicNeo-Regular tahoma Malgun Gothic b9d1c740 \ace0\b515 dotum \b3cb\c6c0 sans-serif text-black m-0 p-0 overflow-hidden absolute w-0 h-0 text-xs leading-0 -ml-9">
제품 상세
</h3>
<Container className="text-base leading-6 font-sans text-gray-700 font-HelveticaNeue AppleSDGothicNeo-Regular tahoma Malgun Gothic b9d1c740 \ace0\b515 dotum \b3cb\c6c0 sans-serif text-black">
<Container className="swiper-theme-color-blue-500 swiper-navigation-size-44 -webkit-text-size-adjust-none text-base leading-6 font-sans text-gray-700 font-HelveticaNeue AppleSDGothicNeo-Regular tahoma Malgun Gothic b9d1c740 \ace0\b515 dotum \b3cb\c6c0 sans-serif text-black p-0 m-auto w-full">
<Container className="swiper-theme-color-blue-500 swiper-navigation-size-44 -webkit-text-size-adjust-none text-base leading-6 font-sans text-gray-700 font-HelveticaNeue AppleSDGothicNeo-Regular tahoma Malgun Gothic b9d1c740 \ace0\b515 dotum \b3cb\c6c0 sans-serif text-black p-0 flex w-1280 m-auto">
{/* layout_split*/}
<Container className="swiper-theme-color-blue-500 swiper-navigation-size-44 -webkit-text-size-adjust-none text-base leading-6 font-sans text-gray-700 font-HelveticaNeue AppleSDGothicNeo-Regular tahoma Malgun Gothic b9d1c740 \ace0\b515 dotum \b3cb\c6c0 sans-serif text-black m-0 w-890 p-30 pr-29 pb-150 border-r-1 border-gray-300">
{/* product section */}
<h3 className="font-HelveticaNeue AppleSDGothicNeo-Regular tahoma Malgun Gothic b9d1c740 \ace0\b515 dotum \b3cb\c6c0 sans-serif text-black m-0 p-0 overflow-hidden absolute w-0 h-0 text-xs leading-0 -ml-9">
제품 상세
</h3>
<Container className="relative z-20 flex pb-4">
<Container className="swiper-container flex-0 flex-shrink-0 w-430">
<Photo
src={detail.detail.image}
alt={detail.detail.productName}
></Photo>
</Container>
<Container class="swiper-container flex-shrink-0 w-430 mx-auto">
{"설명란"}
</Container>
</Container>
</Container>
<Container className="swiper-theme-color-blue-500 swiper-navigation-size-44 -webkit-text-size-adjust-none text-base leading-6 font-sans text-gray-700 font-HelveticaNeue AppleSDGothicNeo-Regular tahoma Malgun Gothic b9d1c740 \ace0\b515 dotum \b3cb\c6c0 sans-serif text-black m-0 p-0 relative w-360 bg-white">
{"옵션란" /* purchase section */}
</Container>
</Container>
</Container>
</Container>
</Container>
);
};
export default ProductDetail;
useRef
useState
구분하여 사용하기
useRef
useState
ProductInformationColumn.jsx
스타일 설정OptionColumn.jsx
import { useState } from "react";
import Counter from "../atoms/Counter";
import { useMutation } from "react-query";
import { addCart } from "../../services/cart";
import Button from "../atoms/Button";
import { comma } from "../../utils/convert";
import OptionList from "../atoms/OptionList";
const OptionColumn = ({ product }) => {
const [selectiedOptions, setSelectiedOptions] = useState([]);
const handleOnClickOption = (option) => {
//동일 옵션 클릭 방지 코드
const isOptionSelected = selectiedOptions.find(
(el) => el.optionId === option.id
);
if (isOptionSelected) {
setSelectiedOptions((prev) =>
prev.map((el) =>
el.optionId === option.id ? { ...el, quantity: el.quantity + 1 } : el
)
);
return;
}
setSelectiedOptions((prev) => [
...prev,
{
optionId: option.id,
quantity: 1,
price: option.price,
name: option.optionName,
},
]);
};
const handleOnChange = (count, optionId) => {
setSelectiedOptions((prev) => {
//추가예정
return prev.map((el) => {
if (el.optionId === optionId) {
return {
...el,
quantity: count,
};
}
return el;
});
});
};
const { mutate } = useMutation({
mutationFn: addCart,
});
return (
<div className="option-column w-full h-full mt-10 pt-10 bg-white">
<h3 className="text-left text-l font-bold boarder-2 border-inherit border-black block">
옵션 선택
</h3>
{/*옵션 선택 영역 */}
<OptionList options={product.options} onClick={handleOnClickOption} />
<hr />
{selectiedOptions.map((option) => (
<ol key={`${option.id}`} className="selected-option-list">
<div className="bg-slate-300 rounded-md my-2 px-5 py-3">
<li className="selected-option">
<div className="text-left">
<span calssName="name">{option.name}</span>
</div>
<Counter
className="float-left"
onIncrease={(count) => handleOnChange(count, option.optionId)}
onDecrease={(count) => handleOnChange(count, option.optionId)}
/>
<span calssName="price float-right">{comma(option.price)}원</span>
</li>
</div>
</ol>
))}
<div className="total-price mx-10">
<span className=" float-left">
총 수량:
{comma(
selectiedOptions.reduce((acc, cur) => {
return acc + cur.quantity;
}, 0)
)}
개
</span>
<span className="float-right">
총 상품 금액:
{comma(
selectiedOptions.reduce((acc, cur) => {
return acc + cur.quantity * cur.price;
}, 0)
)}
원
</span>
</div>
<div className="button-group">
{/* 장바구니 담기 버튼 위치 */}
<Button
className="bg-yellow-500 hover:bg-yellow-600 h-auto text-white font-bold py-2 px-1 w-2/3 mt-10 rounded cursor-pointer transition-colors duration-300"
onClick={() => {
mutate(
selectiedOptions.map((el) => {
return {
optionId: el.optionId,
quantity: el.quantity,
};
}),
{
onSuccess: () => {
alert("success");
},
onError: (e) => {
console.log(e);
alert("failed");
},
}
);
}}
>
구매하기
</Button>
{/* 톡딜가 구매 : 개발 x */}
<Button
className="bg-gray-500 hover:bg-yellow-600 text-white font-bold py-2 px-1 w-2/3 rounded cursor-pointer transition-colors duration-300"
onClick={() => {}}
>
톡딜가 구매하기
</Button>
</div>
</div>
);
};
export default OptionColumn;
OptionList.jsx
import { comma } from "../../utils/convert";
const OptionList = ({ options, onClick }) => {
return (
<div className="h-40 overflow-y-auto">
<ol className="option-list text-left border-2 h-300 border-zinc-300 overflow-y-auto">
<h4 className="text-left font-bold border-zinc-300 border-b-2">구성</h4>
{options.map((option, index) => (
<li
key={option.id}
className="option px-3 py-3 border-zinc-300 border-b-2 "
onClick={() => onClick(option)}
>
<div className="">
<span className="name mr-3 boarder-b-4 border-solid boarder-slate-300">
{index + 1}. {option.optionName}
</span>
</div>
<div className="font-bold">
<span className="price">{comma(option.price)}원</span>
</div>
</li>
))}
</ol>
</div>
);
};
export default OptionList;
return (
<div>
{isLoading && <Loader />}
{error && <ErrorPage />}
{data && <ProductDetailTemplate product={product} />}
</div>
);
error
를 파라미터로 가지고 들어가는 경우 404가 아니라 다른 경우에도 사용할 수 있도록 페이지를 수정하였고, 없는 경우 404가 뜰 수 있도록 적용해놓았다.import "../styles/pages/error.css";
const ErrorPage = ({ error }) => {
let errorNum = 404;
if (error) {
errorNum = error.response.status;
}
return (
<div id="notfound">
<div class="notfound">
<div class="notfound-404">
<h3>Oops! Page not found</h3>
<h1>
<span>{error ? parseInt(errorNum / 100) : 4}</span>
<span>{error ? parseInt((errorNum % 100) / 10) : 4}</span>
<span>{error ? parseInt(errorNum % 10) : 4}</span>
</h1>
</div>
{error ? (
<h2>{error.message}</h2>
) : (
<h2>we are sorry, but the page you requested was not found</h2>
)}
</div>
</div>
);
};
export default ErrorPage;
/product/18
요청 시 ( 없는 요청 )Loader
작동404page
/carts/add
후 요청을 해야한다면 ? → 원래 장바구니에 있는 것도 다 같이 사짐500
번 에러 발생selectionOptions
의 내용과 현재 /carts
로 조회한 장바구니와 겹치는 내용에 대해 비교해서 겹칠 경우 수량만 증가시켜 장바구니 수정 요청, 나머지는 담기 요청const checkCart = ({ products }) => {
products.map((el) => {
if (el.id === product.id) {
el.carts.map((cart) => {
selectiedOptions.map((item) => {
console.log(cart);
if (cart.option.id === item.optionId) {
const isOptionSelected = modifiedOptions.find(
(mod) => mod.cartId === cart.id
);
if (isOptionSelected) {
setModifiedOptions((prev) =>
prev.map((mod) =>
mod.cartId === cart.id
? { ...mod, quantity: mod.quantity + cart.quantity }
: mod
)
);
} else {
setModifiedOptions((prev) => [
...prev,
{ cartId: cart.id, quantity: item.quantity + cart.quantity },
]);
}
}
});
console.log(cart.option.id);
setSelectiedOptions(
selectiedOptions.filter((item) => item.optionId !== cart.option.id)
);
});
}
});
};
{mutate: mu2}
와 같이 해결했다.CartList.jsx
import Container from "../atoms/Container";
import Box from "../atoms/Box";
import { useCallback, useEffect, useState } from "react";
import { comma } from "../../utils/convert";
import Card from "../atoms/Card";
import CartItem from "../atoms/CartItem";
import Button from "../atoms/Button";
import { useMutation } from "react-query";
import { modifiedCart } from "../../services/cart";
const CartList = ({ data }) => {
const [cartItems, setCartItems] = useState([]);
const [totalPrice, setTotalPrice] = useState(0);
const { mutate } = useMutation({
mutationFn: modifiedCart,
});
useEffect(() => {
setCartItems(data?.data?.response?.products);
setTotalPrice(data?.data?.response.totalPrice);
}, [data]);
const getTotalCart = useCallback(() => {
let count = 0;
cartItems.forEach((item) => {
item.carts.forEach((cart) => {
count += cart.quantity;
});
});
return comma(count);
}, [cartItems]);
const handleOnChangeCount = (optionId, quantity, price) => {
setTotalPrice((prev) => prev + price);
setCartItems((prev) => {
return prev.map((item) => {
return {
...item,
carts: item.carts.map((cart) => {
if (cart.id === optionId) {
return { ...cart, quantity: quantity };
}
return cart;
}),
};
});
});
};
return (
<Container className="cart-list">
<Box>
<h1>장바구니</h1>
</Box>
<Card>
{/*상품 별 장바구니 항목 */}
{Array.isArray(cartItems) &&
cartItems.map((item) => {
return (
<CartItem
key={item.id}
item={item}
onChage={handleOnChangeCount}
/>
);
})}
</Card>
<Card>
<div className="row">
<span className="expect">예상 주문금액</span>
<div className="sum-price">{comma(totalPrice)}원</div>
</div>
</Card>
<Button className="order-button" onClick={() => {}}>
{
//cart update
mutate()
//navigate to page
}
</Button>
<span>총 {getTotalCart()}건 주문하기</span>
</Container>
);
};
export default CartList;
CartPage.jsx
import { Suspense } from "react";
import { inCart } from "../services/cart";
import { useQuery } from "react-query";
import Loader from "../components/atoms/Loader";
import CartList from "../components/molecules/CartList";
const CartPage = () => {
const { data } = useQuery("cart", () => inCart());
return (
<Suspense fallback={<Loader />}>
<CartList data={data} />
</Suspense>
);
};
export default CartPage;
CartItem.jsx
import { comma } from "../../utils/convert";
import Box from "./Box";
import Card from "./Card";
import Counter from "./Counter";
const CartItem = ({ item, onChange }) => {
return (
<Box className="cart-item-box">
<h5>{item.productName}</h5>
{item.carts.map((cart) => {
<Card key={cart.id} className="cart">
<div className="option-name">
<span>{cart.option.optionName}</span>
</div>
<div className="row">
<Counter
onIncrease={(count) =>
onChange(cart.id, count, cart.option.price)
}
onDecrease={(count) =>
onChange(cart.id, count, cart.option.price)
}
/>
<div className="price">
<span>{comma(cart.option.price * cart.quantity)}원</span>
</div>
</div>
</Card>;
})}
<Card className="total-price">
<div className="row">
<h5>주문금액</h5>
<div className="price">
{comma(
item.carts.reduce((acc, cur) => {
return acc + cur.option.price * cur.quantity;
}, 0)
)}
원
</div>
</div>
</Card>
</Box>
);
};
export default CartItem;
import Container from "../atoms/Container";
import Box from "../atoms/Box";
import { useEffect, useRef, useState } from "react";
import { comma } from "../../utils/convert";
import Card from "../atoms/Card";
import CartItem from "../atoms/CartItem";
import Button from "../atoms/Button";
import { useMutation } from "react-query";
import { modifiedCart } from "../../services/cart";
import { useNavigate } from "react-router-dom";
const CartList = ({ data }) => {
const navigate = useNavigate();
const initPayload = useRef([]);
const [cartItems, setCartItems] = useState([]);
const [totalPrice, setTotalPrice] = useState(0);
const [updatePayload, setUpdatePayload] = useState([]);
const { mutate } = useMutation({
mutationFn: modifiedCart,
});
useEffect(() => {
setCartItems(data?.data?.response?.products);
setTotalPrice(data?.data?.response.totalPrice);
}, [data]);
useEffect(() => {
initPayload.current = data?.data?.response?.products;
}, []);
const getTotalCart = () => {
let count = 0;
if (Array.isArray(cartItems)) {
cartItems.forEach((item) => {
item.carts.forEach((cart) => {
count += cart.quantity;
});
});
}
return comma(count);
};
const handleOnChangeCount = (optionId, quantity, price) => {
setUpdatePayload((prev) => {
const isExist = prev.find((item) => item.cartId === optionId);
if (isExist) {
if (quantity < 1) {
return [
...prev.filter((item) => item.cartId !== optionId),
{
cartId: optionId,
quantity: 0,
},
];
}
return [
...prev.filter((item) => item.cartId !== optionId),
{
cartId: optionId,
quantity,
},
];
}
return [
...prev,
{
cartId: optionId,
quantity,
},
];
});
setTotalPrice((prev) => prev + price);
setCartItems((prev) => {
return prev.map((item) => {
return {
...item,
carts: item.carts.map((cart) => {
if (cart.id === optionId) {
return { ...cart, quantity: quantity };
}
return cart;
}),
};
});
});
};
return (
<Container className="cart-list ">
<Box className="pt-4">
<h1>장바구니</h1>
</Box>
<Card>
{/*상품 별 장바구니 항목 */}
{Array.isArray(cartItems) &&
cartItems.map((item) => {
return (
<CartItem
key={item.id}
item={item}
onChange={handleOnChangeCount}
/>
);
})}
</Card>
<Card>
<div className="row border-2 border-slate-200 bg-white">
<span className="expect font-bold">주문 예상금액</span>
<div className="sum-price">{comma(totalPrice)}원</div>
</div>
</Card>
<Button
className="order-button bg-gray-500 hover:bg-yellow-600 text-white font-bold py-2 px-1 w-full rounded cursor-pointer transition-colors duration-300"
onClick={() => {
mutate(updatePayload, {
onSuccess: (data) => {
navigate("/order");
},
onError: (err) => {},
});
}}
>
<span>총 {getTotalCart()}건 주문하기</span>
</Button>
</Container>
);
};
export default CartList; //CartList.jsx
import { comma } from "../../utils/convert";
import Box from "./Box";
import Card from "./Card";
import Counter from "./Counter";
import Photo from "./Photo";
const CartItem = ({ item, onChange }) => {
return (
<Box className="cart-item-box border-t-2 border-b-2 border-solid bg-white p-16 rounded-lg">
<div className="flex ">
<div className="w-20 h-20 mx-5 mt-2 ">
<Photo src={`/images/${item.id}.jpg`} />
</div>
<h5 className=" ml-5 mt-5 h-5">{item.productName}</h5>
</div>
{item.carts.map((cart) => {
return (
<Card
key={cart.id}
className="cart block border-2 border-slate-200 m-2"
>
<div className="option-name">
<span className="text-black">{cart.option.optionName}</span>
</div>
<div className="row">
<Counter
onIncrease={(count) =>
onChange(cart.id, count, cart.option.price)
}
onDecrease={(count) =>
onChange(cart.id, count, cart.option.price)
}
/>
<div className="price">
<span>{comma(cart.option.price * cart.quantity)}원</span>
</div>
</div>
</Card>
);
})}
<Card className="total-price ">
<div className="row">
<h5 className="px-10 text-center font-bold">주문금액</h5>
<div className="price">
{comma(
item.carts.reduce((acc, cur) => {
return acc + cur.option.price * cur.quantity;
}, 0)
)}
원
</div>
</div>
</Card>
</Box>
);
};
export default CartItem; //CartItem.jsx
음 .. 굉장히 정신없고 힘든 두 주였다. 포스팅을 까먹었으니..
react 자체에 대해 다뤄본 적이 전혀 없어서 그럴 수 있겠지만 단순한 문법 하나 하나 익숙치 않아 서칭을 해야하는 경우가 많았다. 익숙해지겠지..
그래도 꽤 얻어 가는 부분이 많은 것 같다. useState
와 useRef
를 구분해 사용하는 것도 알게 되고 useCallback
을 통해 함수 재사용시 렌더링을 효과적으로 하는 방법 등 cs적으로도 많이 배우며 프로젝트를 진행할 수 있고 멘토님의 피드백과 멘토링도 조만간 신청할 예정이다.