doridori-samsam / hodu-open-market

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

πŸ’šν˜Έλ‘ μ˜€ν”ˆλ§ˆμΌ“

πŸ“Œ 2022.09 - 2022.10
πŸ“Œ ν˜Έλ‘λ§ˆμΌ“ 배포 URL : https://hodumarket.netlify.app/

πŸ“„κ°œμš”

ν˜Έλ‘λ§ˆμΌ“μ€ λˆ„κ΅¬λ‚˜ 자유둭게 μƒν’ˆμ„ κ²Œμ‹œν•˜μ—¬ νŒλ§€ν•˜κ³  ꡬ맀할 수 μžˆλŠ” μ˜€ν”ˆλ§ˆμΌ“ μ„œλΉ„μŠ€μž…λ‹ˆλ‹€.

νšŒμ›μ€ 판맀자/ꡬ맀자 μœ ν˜•μœΌλ‘œ λ‚˜λ‰˜λ©° νŒλ§€μžλŠ” μƒν’ˆ 정보λ₯Ό κ²Œμ‹œ,μˆ˜μ •,μ‚­μ œ ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

κ΅¬λ§€μžλŠ” μ›ν•˜λŠ” μƒν’ˆμ„ μ›ν•˜λŠ” μˆ˜λŸ‰λ§ŒνΌ μž₯λ°”κ΅¬λ‹ˆμ— λ‹΄κ±°λ‚˜ λ°”λ‘œ ꡬ맀λ₯Ό ν•  수 μžˆμŠ΅λ‹ˆλ‹€.


βš™κΈ°μˆ  및 κ°œλ°œν™˜κ²½

[기술]


πŸ“Œ BackEnd : 제곡된 API μ‚¬μš©
πŸ“Œ Daum Postcode Service API μ‚¬μš©
πŸ“Œ Version :
react : "18.2.0"
react-router-dom : "6.3.0"
axios: "0.27.2",
react-daum-postcode: "3.1.1"
react-intersection-observer: "9.4.0"
react-query: "3.39.2"
tailwindcss: "3.1.8"
vite: "3.0.7"

[κ°œλ°œν™˜κ²½]



πŸŽ¨κ΅¬ν˜„ κΈ°λŠ₯

  • πŸ” 계정

    • 둜그인/λ‘œκ·Έμ•„μ›ƒ
    • ꡬ맀자 / 판맀자 νšŒμ›κ°€μž…
    • μœ νš¨μ„± 검증
    • 토큰 검증
  • 🏠 ν™ˆ

    • μƒν’ˆ 검색
    • μƒν’ˆ λͺ©λ‘
    • λ¬΄ν•œ 슀크둀
  • 🎁 μƒν’ˆ

    • μƒν’ˆ 상세 νŽ˜μ΄μ§€
    • μƒν’ˆ μˆ˜λŸ‰ 선택
    • μž₯λ°”κ΅¬λ‹ˆ μƒν’ˆ λ‹΄κΈ°
    • μƒν’ˆ μ£Όλ¬Έ 및 결제
    • μƒν’ˆ 재고 유효 검사
  • πŸ›’ μž₯λ°”κ΅¬λ‹ˆ

    • μž₯λ°”κ΅¬λ‹ˆμ— λ‹΄κΈ΄ μƒν’ˆ λͺ©λ‘ 확인
    • μƒν’ˆ μˆ˜λŸ‰ μˆ˜μ • 및 μƒν’ˆ μ‚­μ œ
    • μƒν’ˆ κ°œλ³„ / 전체 선택
    • μ„ νƒλœ μƒν’ˆμ˜ 총 할인 / 배솑비 / μƒν’ˆκ°€κ²© 확인
    • μ„ νƒλœ μƒν’ˆ ν˜Ήμ€ κ°œλ³„ μ£Όλ¬Έ
  • πŸ‘¨β€πŸŒΎ 판맀자 μ„Όν„°

    • μƒν’ˆ 등둝 및 μ‚­μ œ
    • λ“±λ‘λœ μƒν’ˆ μˆ˜μ •
  • ETC

    • λͺ¨λ°”일 μœ μ €λ₯Ό μœ„ν•œ λ°˜μ‘ν˜• λ””μžμΈ κ΅¬ν˜„
    • api 데이터 λ‘œλ”© 쀑 λ‘œλ”© μŠ€ν”Όλ„ˆ ν™”λ©΄ κ΅¬ν˜„


βœ¨μ½”λ“œ 포인트

βœ” react-query useQueries둜 promise all κ΅¬ν˜„

const { data, status } = useQuery(["cart-list", token], getCartList, {
cacheTime: 1000000,
onSuccess: (data) => {
setIsAllChecked(true);
let checkObj = data.reduce((newObj, idx) => {
newObj["check" + data.indexOf(idx)] = true;
return newObj;
}, {});
setCheckList(checkObj);
},
});
const listDetails = useQueries(
!!data
? data.map((item) => {
return {
queryKey: ["info", item.product_id],
queryFn: () => getDetails(item.product_id),
cacheTime: 1000000,
};
})
: []
);
/**μž₯λ°”κ΅¬λ‹ˆ λͺ©λ‘ κ°€μ Έμ˜€κΈ° */
async function getCartList() {
const res = await axios.get(url + "cart/", {
headers: { Authorization: `JWT ${token}` },
});
return res.data.results;
}
/**μž₯λ°”κ΅¬λ‹ˆ λ¦¬μŠ€νŠΈμ—μ„œ 뽑은 product_id둜 μƒν’ˆ 상세정보 κ°€μ Έμ˜€κΈ° */
async function getDetails(id) {
const res = await axios.get(url + "products/" + id + "/");
return res.data;
}
const loadingFinishAll = listDetails.every((item) => item.isSuccess);

κ΅¬λ§€μžκ°€ μž₯λ°”κ΅¬λ‹ˆμ— 담은 μƒν’ˆλͺ©λ‘μ„ 뢈러온 ν›„,
μƒν’ˆ 이름, μƒν’ˆ 가격, 배솑비, 판맀자 이름, 이미지 정보λ₯Ό λ°›μ•„μ˜€κΈ° μœ„ν•΄ μž₯λ°”κ΅¬λ‹ˆ μƒν’ˆλͺ©λ‘μ˜ μƒν’ˆ id둜 λ‹€μ‹œ ν•œλ²ˆ μƒν’ˆ 정보λ₯Ό κ°€μ Έμ™”μŠ΅λ‹ˆλ‹€.
이 λ•Œ μ•„λž˜μ™€ 같이 promise all을 μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

  async function getCartList() {
    try {
      const res = await axios.get(url + "cart/", {
        headers: { Authorization: `JWT ${token}` }
      });
      const items = res.data.results.map((item, idx) =>
        axios.get(url + "products/" + item.product_id + "/")
      );
      const itemsArr = await Promise.all(items);
    } catch (err) {
      console.errir(err);
    }
  }

이처럼 Promise.all을 κ΅¬ν˜„ν•˜κΈ° μœ„ν•΄ react-query의 useQueries 훅을 λ™μ μœΌλ‘œ μ‚¬μš©ν•˜μ˜€μŠ΅λ‹ˆλ‹€.


βœ” prefetch둜 cached된 data μ‚¬μš©μœΌλ‘œ μ„±λŠ₯ ν–₯상

async function getProduct(pageParam) {
const res = await axios.get(url + "products/?page=" + pageParam);
const result = res.data;
setDataLength(Math.ceil(result.count / 15));
return {
result: result.results,
nextPage: pageParam + 1,
isLast: !res.data.next,
};
}
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage]);
/**λͺ¨λ“  μƒν’ˆ λͺ©λ‘ prefetch */
useEffect(() => {
for (let i = 1; i < dataLength + 1; i++) {
queryClient.prefetchQuery(["allItems", `Arr${i}`], () => getAllItems(i), {
staleTime: Infinity,
cacheTime: 86000000,
});
}
}, [dataLength]);
async function getAllItems(i) {
const res = await axios.get(url + "products/?page=" + i);
return res.data.results;
}

ν™ˆνŽ˜μ΄μ§€μ—μ„œ first page μƒν’ˆ api λ°μ΄ν„°μ—μ„œ μƒν’ˆμ˜ 총 개수λ₯Ό ν•œλ²ˆμ— λΆˆλŸ¬μ™€μ§€λŠ” μƒν’ˆλͺ©λ‘ 개수(15)둜 λ‚˜λˆ„κ³  μ˜¬λ¦Όν•΄μ€λ‹ˆλ‹€. 이λ₯Ό dataLength에 ν• λ‹Ήν•©λ‹ˆλ‹€. 그리고 dataLength 만큼 for 반볡문으둜 queryClient.prefetchQuery훅을 μ‚¬μš©ν•©λ‹ˆλ‹€. prefetch된 λͺ¨λ“  μƒν’ˆμ˜ λ°μ΄ν„°λŠ” cachedλ˜μ–΄ μƒν’ˆ κ²€μƒ‰μ‹œμ— μ‚¬μš©λ©λ‹ˆλ‹€.


βœ” μž₯λ°”κ΅¬λ‹ˆμ— μƒν’ˆ μΆ”κ°€ μ‹œ cache된 dataμ‚¬μš© ν•˜μ—¬ μ„±λŠ₯ν–₯상

const cartData = queryClient.getQueryData(["cart-list", token]);
const addToCart = useMutation(clickAddToCart, {
onSuccess: (res) => {
setIsAddCartModalOpen(true);
setIsItemExist(
data.some((item) => item.cart_item_id === res.data.cart_item_id)
);
queryClient.invalidateQueries("cart-list", "info");
},
onError: (error) => {
console.error(error);
},
});
/**μž₯λ°”κ΅¬λ‹ˆ νŽ˜μ΄μ§€λ₯Ό λ°©λ¬Έν•œ 적이 있으면 data fetch X */
const { data, status } = useQuery(["cart-list", token], getCartList, {
enabled: !cartData,
});
async function getCartList() {
if (userType === "BUYER") {
const res = await axios.get(url + "cart/", {
headers: { Authorization: `JWT ${token}` },
});
return res.data.results;
}
}

κ΅¬λ§€μžκ°€ μž₯λ°”κ΅¬λ‹ˆμ— μƒν’ˆ μΆ”κ°€ μ‹œ, μž₯λ°”κ΅¬λ‹ˆμ— ν•΄λ‹Ή μƒν’ˆμ΄ 기쑴에 μ‘΄μž¬ν•˜λŠ”μ§€ κ²€μ¦ν•˜κΈ° μœ„ν•΄ μ‚¬μš©μž μž₯λ°”κ΅¬λ‹ˆ 데이터λ₯Ό λΆˆλŸ¬μ˜΅λ‹ˆλ‹€. 이 λ•Œ, μ‚¬μš©μžκ°€ μž₯λ°”κ΅¬λ‹ˆ νŽ˜μ΄μ§€λ₯Ό 이미 λ°©λ¬Έν•œ 적이 있으면 dataλ₯Ό fetch ν•˜μ§€ μ•Šκ³ , useQueryClient훅을 μ‚¬μš©ν•˜μ—¬ getQueryData둜 μž₯λ°”κ΅¬λ‹ˆ νŽ˜μ΄μ§€μ—μ„œ cache된 데이터λ₯Ό λΆˆλŸ¬μ˜΅λ‹ˆλ‹€.


βœ” λ¬΄ν•œ 슀크둀 κ΅¬ν˜„

const [ref, inView] = useInView();
const { data, status, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery("products", ({ pageParam = 1 }) => getProduct(pageParam), {
getNextPageParam: (lastPage, allPages) => {
if (!lastPage.isLast) {
return lastPage.nextPage;
} else {
return undefined;
}
},
});
async function getProduct(pageParam) {
const res = await axios.get(url + "products/?page=" + pageParam);
const result = res.data;
setDataLength(Math.ceil(result.count / 15));
return {
result: result.results,
nextPage: pageParam + 1,
isLast: !res.data.next,
};
}
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage]);

react-query의 useInfiniteQuery와 react-intersection-observer apiλ₯Ό μ‚¬μš©ν•˜μ—¬ λ¬΄ν•œ 슀크둀 κΈ°λŠ₯을 κ΅¬ν˜„ν•˜μ˜€μŠ΅λ‹ˆλ‹€.
useInView의 'ref'λŠ” 각 νŽ˜μ΄μ§€ λ§ˆλ‹€ λ§ˆμ§€λ§‰ μƒν’ˆ(15번째)에 μ§€μ •ν•˜μ˜€μŠ΅λ‹ˆλ‹€.


βœ” λ‹€μŒ 우편번호 API μ„œλΉ„μŠ€λ‘œ 우편번호 검색 κΈ°λŠ₯ κ΅¬ν˜„

import ModalPortal from "./ModalPortal";
import DaumPostCode from "react-daum-postcode";
function PostCodeModal({ open, close, onComplete }) {
const themeObj = {
bgColor: "#F9F9F9", //바탕 배경색
pageBgColor: "#FFFFFF", //νŽ˜μ΄μ§€ 배경색
postcodeTextColor: "#21BF48", //우편번호 κΈ€μžμƒ‰
emphTextColor: "#EB5757", //κ°•μ‘° κΈ€μžμƒ‰
outlineColor: "#21BF48", //ν…Œλ‘λ¦¬
};
const postCodeStyle = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "450px",
height: "470px",
};
return (
<>
{open ? (
<>
<ModalPortal close={close}>
<aside className="flex w-[200px] h-[300px] borer-[1px] border-pink-500">
<DaumPostCode
onComplete={onComplete}
theme={themeObj}
style={postCodeStyle}
/>
</aside>
</ModalPortal>
</>
) : null}
</>
);
}
export default PostCodeModal;

/**우편번호 쑰회 λ²„νŠΌ 클릭 μ‹œ λͺ¨λ‹¬ μ°½ λ„μš°κΈ° */
function openPostCodeModal() {
setIsModalOpen(true);
}
/**우편번호 검색 ν›„ 선택 μ™„λ£Œ*/
function setPostCode(data) {
setZipCode(data.zonecode);
setMainAddress(data.address);
setIsModalOpen(false);
}

λ‹€μŒμΉ΄μΉ΄μ˜€μ—μ„œ μ œκ³΅ν•˜λŠ” 우편번호 쑰회 API μ„œλΉ„μŠ€λ₯Ό μ΄μš©ν•˜μ—¬ 우편 번호 검색 κΈ°λŠ₯을 κ΅¬ν˜„ν•˜μ˜€μŠ΅λ‹ˆλ‹€.
κ΅¬λ§€μžκ°€ μƒν’ˆ μ£Όλ¬Έ μ‹œ, 배솑 μ£Όμ†Œ μž…λ ₯μ—μ„œ 우편번호찾기λ₯Ό ν΄λ¦­ν•˜λ©΄ 우편번호 검색 λͺ¨λ‹¬μ°½μ΄ λ‚˜νƒ€λ‚©λ‹ˆλ‹€.
μ‚¬μ΄νŠΈμ˜ κΈ°λ³Έ μ»¬λŸ¬νŒ”λ ˆνŠΈμ™€ μ–΄μšΈλ¦¬λ„λ‘ μ£Όμ†Œ 검색창 색상을 μˆ˜μ •ν•˜μ˜€μŠ΅λ‹ˆλ‹€.



πŸ’£μ΄μŠˆ

- tailwind둜 λ™μ μœΌλ‘œ background Image urlμ„€μ •μ‹œ 보이지 μ•ŠλŠ” 이슈.

function ProductList({ listdata, lastItemRef }) {
const navigate = useNavigate();
return (
<section className={`${styles.flexCenter} ${styles.sectionLayout}`}>
<ul className="w-full grid lg:grid-cols-[repeat(3,350px)] md:grid-cols-[repeat(3,300px)] sl:grid-cols-[repeat(3,220px)] sm:grid-cols-[repeat(2, 220px)] ss:grid-cols-[repeat(2,200px)] grid-cols-[repeat(2,150px)] gap-y-[50px] justify-between">
{listdata.map((list, idx) => {
return (
<li key={list.product_id}>
<div
onClick={() =>
navigate(`/products/${list.product_id}`, {
state: { product: list },
})
}
className={`lg:w-[350px] lg:h-[350px] md:w-[300px] md:h-[300px] sl:w-[220px] sl:h-[220px] ss:w-[200px] ss:h-[200px] w-[150px] h-[150px] rounded-[10px] border-[1px] bg-center bg-cover cursor-pointer`}
style={{ backgroundImage: `url(${list.image})` }}
></div>
<p className="md:text-[16px] sm:text-[14px] text-[11px] text-subText font-spoqa">
{list.store_name}
</p>
<p
className={`w-full md:text-[18px] sm:text-[16px] text-[13px] text-mainText font-spoqa ${styles.textEllipsis}`}
>
{list.product_name}
</p>
<span
ref={idx === listdata.length - 1 ? lastItemRef : null}
className="inline-block md:text-[24px] sm:text-[22px] text-[14px] text-mainText font-spoqaBold"
>
{list.price.toLocaleString()}
</span>
<span className="inline sm:text-[16px] text-[13px] text-mainText font-spoqa">
{" "}
원
</span>
</li>
);
})}
</ul>
</section>
);
}
API둜 뢈러온 데이터λ₯Ό μ‚¬μš©ν•˜μ—¬ λ™μ μœΌλ‘œ background Image의 url을 μ„€μ •ν•  λ•Œ, tailwind둜 μ„€μ • μ‹œ 이미지가 λ Œλ”λ§ λ˜μ§€ μ•ŠλŠ” μ΄μŠˆκ°€ μžˆμ—ˆμŠ΅λ‹ˆλ‹€.
tailwind의 였λ₯˜κ°€ μžˆλŠ” κ²ƒμœΌλ‘œ 보여, 인라인 μ†μ„±μœΌλ‘œ background Image url을 μ„€μ •ν•΄ μ£Όμ—ˆμŠ΅λ‹ˆλ‹€.



πŸ“‚ν΄λ”νŠΈλ¦¬

πŸ“¦ src
 β”£πŸ“‚ assets
 β”£πŸ“‚ components
 ┃ β”£πŸ“‚ buttons
 ┃ β”£πŸ“‚ footer
 ┃ β”£πŸ“‚ modal
 ┃ β”£πŸ“‚ navBar
 ┃ ┣ NotFound.jsx
 ┃ ┣ NowLoading.jsx
 ┃ β”— SmNowLoading.jsx
 β”£πŸ“‚ context
 β”£πŸ“‚ pages
 ┃ β”£πŸ“‚ auth
 ┃ β”£πŸ“‚ home
 ┃ β”£πŸ“‚ myCart
 ┃ β”£πŸ“‚ payment
 ┃ β”£πŸ“‚ productDetail
 ┃ β”£πŸ“‚ productSearch
 ┃ β”£πŸ“‚ sellerCenter
 ┃ β”—πŸ“‚ sellerProductsUpload
 β”£πŸ“œ App.jsx
 β”£πŸ“œ main.jsx
 β”£πŸ“œ index.css
 β”—πŸ“œ style.js


About


Languages

Language:JavaScript 87.7%Language:CSS 12.1%Language:HTML 0.2%