미새문지

24.09.11 day65 작업 일지 본문

개발 TIL

24.09.11 day65 작업 일지

문미새 2024. 9. 11. 23:57
728x90

랭킹 페이지에서 UI로 만들어논 필터의 기능을 구현하려 코드를 봤는데 컴포넌트가 좀 깊게 들어가 있었다.

ranking => rankingList => rankingFilter => main, subFilter까지 총 4중으로 깊게 들어가 있어 데이터를 어떻게 전달해야 할지 고민을 좀 많이 했다.

구현은 props로 전달하는 방식으로 했지만 코드가 4중으로 컴포넌트를 타고 들어가는 방식이라 너무 조잡해보이긴 했다. 그래도 리덕스로 하지 않은 이유는 구글링하며 찾아봤더니 상태관리를 이용하면 코드를 타고가지 않아도 데이터값을 가져올 수 있지만 특정 값만 상태관리를 위해 리덕스로 구현하는건 props에 비해 클린 코딩이 되지 않는다고 하더라.

 

import styled from "styled-components";
import RankingFilter from "./rankingFilter";
import RankingItem from "./rankingItem";
import { useEffect, useState } from "react";
import axios from "axios";

const RankingList: React.FC = () => {
  const [mainFilter, setMainFilter] = useState<string>("");
  const [subFilter, setSubFilter] = useState<string>("");
  const [rankingData, setRankingData] = useState<any[]>([]);
  const backPort = process.env.REACT_APP_BACKEND_PORT;

  const fetchRankingData = async () => {
    try {
      const response = await axios.post(`${backPort}/api/item`);
      setRankingData(response.data);
    } catch (error) {
      console.error("랭킹 데이터 가져오기 실패:", error);
    }
  };

  useEffect(() => {
    fetchRankingData();
  }, []);

  return (
    <>
      <RankingListWrapper>
        <RankingFilter
          mainFilter={mainFilter}
          setMainFilter={setMainFilter}
          subFilter={subFilter}
          setSubFilter={setSubFilter}
        />
        <RankingItem rankingData={rankingData} />
      </RankingListWrapper>
    </>
  );
};
export default RankingList;

const RankingListWrapper = styled.div`
  width: 90%;
  margin: 0 auto;
`;

대신에 ranking 페이지가 아닌 rankingList 컴포넌트에서 데이터를 상태관리하여 컴포넌트 중첩을 3중으로 줄였다.

main필터와 sub필터 값을 useState를 이용해 상태관리를 해주고 랭킹제품 정보를 위해 제품 정보를 받아줄 변수도 상태관리 해준다.

제품은 post 방식의 axios를 사용해 /api/item으로 요청을 보내 응답한 데이터값을 rankingData에 담아준다. 그리고 useEffect를 이용해 페이지가 렌더링되면서 데이터가 불러와지게 했다.

import { useEffect, useState } from "react";
import RankingMainFilter from "./rankingMainFilter";
import RankingSubFilter from "./rankingSubFilter";

interface RankingFilterProps {
  mainFilter: string;
  setMainFilter: (filter: string) => void;
  subFilter: string;
  setSubFilter: (filter: string) => void;
}

const RankingFilter: React.FC<RankingFilterProps> = ({
  mainFilter,
  setMainFilter,
  subFilter,
  setSubFilter,
}) => {
  useEffect(() => {
    console.log("메인필터", mainFilter);
    console.log("서브필터", subFilter);
  }, [subFilter]);

  return (
    <>
      <div>
        <RankingMainFilter select={mainFilter} setSelect={setMainFilter} />
        <RankingSubFilter
          mainFilter={mainFilter}
          subFilter={subFilter}
          setSubFilter={setSubFilter}
        />
      </div>
    </>
  );
};
export default RankingFilter;

자식 컴포넌트에 필터의 props를 넘겨서 해당 컴포넌트의 자식 컴포넌트인 메인 필터와 서브 필터에 그대로 넘겨준다.

import { useState } from "react";
import styled from "styled-components";

interface RankingMainFilterProps {
  select: string;
  setSelect: (filter: string) => void;
}

const RankingMainFilter: React.FC<RankingMainFilterProps> = ({
  select,
  setSelect,
}) => {
  const handleSelect = (filter: string) => {
    setSelect(filter);
  };

  return (
    <>
      <RankingMainFilterWrapper>
        <FilterItem
          isSelected={select === "category"}
          onClick={() => handleSelect("category")}
        >
          카테고리별
        </FilterItem>
        <FilterItem
          isSelected={select === "skin"}
          onClick={() => handleSelect("skin")}
        >
          피부별
        </FilterItem>
        <FilterItem
          isSelected={select === "age"}
          onClick={() => handleSelect("age")}
        >
          연령대별
        </FilterItem>
      </RankingMainFilterWrapper>
    </>
  );
};
export default RankingMainFilter;

const RankingMainFilterWrapper = styled.div`
  width: 90%;
  margin: 30px auto 20px auto;
  display: flex;
  justify-content: space-evenly;
  border-bottom: 1px solid #ffa4e4;
  padding-bottom: 15px;
`;

const FilterItem = styled.div<{ isSelected: boolean }>`
  cursor: pointer;
  font-weight: ${({ isSelected }) => (isSelected ? "bold" : "normal")};
`;

메인 필터는 props로 받아온 변수에 해당 필터의 value값을 넣어주게 구현되어 있다.

 

import { useRef, useState } from "react";
import styled from "styled-components";
import { categoryFilters, skinFilters, ageFilters } from "../../data/Data.js";

interface RankingSubFilterProps {
  mainFilter: string;
  subFilter: string;
  setSubFilter: (filter: string) => void;
}

const RankingSubFilter: React.FC<RankingSubFilterProps> = ({
  mainFilter,
  subFilter,
  setSubFilter,
}) => {
  // 드래깅
  const wrapperRef = useRef<HTMLDivElement>(null);
  const [isDragging, setIsDragging] = useState(false);
  const [startX, setStartX] = useState<number>(0);
  const [scrollLeft, setScrollLeft] = useState<number>(0);

  const handleSelect = (filter: string) => {
    setSubFilter(filter);
  };

  const handleMouseDown = (e: React.MouseEvent) => {
    if (!wrapperRef.current) return;
    setIsDragging(true);
    setStartX(e.pageX - wrapperRef.current.offsetLeft);
    setScrollLeft(wrapperRef.current.scrollLeft);
  };

  const handleMouseLeaveOrUp = () => {
    setIsDragging(false);
  };

  const handleMouseMove = (e: React.MouseEvent) => {
    if (!isDragging || !wrapperRef.current) return;
    const x = e.pageX - wrapperRef.current.offsetLeft;
    const walk = (x - startX) * 2; // 스크롤 속도 조절
    wrapperRef.current.scrollLeft = scrollLeft - walk;
  };

  // mainFilter에 따라 다른 서브 필터 목록을 설정
  const getSubFilterItems = () => {
    if (mainFilter === "category") {
      return categoryFilters;
    } else if (mainFilter === "skin") {
      return skinFilters;
    } else if (mainFilter === "age") {
      return ageFilters;
    } else {
      return [];
    }
  };

  const subFilters = getSubFilterItems();

  return (
    <>
      <RankingSubFilterWrapper
        ref={wrapperRef}
        onMouseDown={handleMouseDown}
        onMouseLeave={handleMouseLeaveOrUp}
        onMouseUp={handleMouseLeaveOrUp}
        onMouseMove={handleMouseMove}
      >
        {subFilters.map((filter) => (
          <FilterItem
            key={filter}
            isSelected={subFilter === filter}
            onClick={() => handleSelect(filter)}
          >
            {filter}
          </FilterItem>
        ))}
      </RankingSubFilterWrapper>
    </>
  );
};
export default RankingSubFilter;

const RankingSubFilterWrapper = styled.div`
  width: 90%;
  font-size: 13px;
  margin: 0 auto;
  display: flex;
  justify-content: space-evenly;
  overflow-x: hidden;
  flex-wrap: nowrap;
  padding-bottom: 10px;
`;

const FilterItem = styled.div<{ isSelected: boolean }>`
  cursor: pointer;
  text-align: center;
  font-weight: ${({ isSelected }) => (isSelected ? "bold" : "normal")};
  white-space: nowrap;
  min-width: 50px;
`;

그리고 서브 필터에서는 각 메인 필터에 맞게 카테고리를 바꿔줘야 하기 때문에 Data에 각 필터 카테고리의 데이터를 배열로 저장해 메인 필터가 무엇이냐에 따라 서브 필터를 다르게 가져오게 구현했다.

export const categoryFilters = [
  "로션",
  "크림",
  "토너",
  "에센스",
  "앰플",
  "선크림",
  "린스",
  "파우더",
  "립밤",
];

export const skinFilters = [
  "건성",
  "지성",
  "중성",
  "복합성",
  "민감성",
  "여드름",
  "아토피",
  "등등",
  "등등등",
];

export const ageFilters = ["10대", "20대", "30대", "40대+"];

 

import styled from "styled-components";
import ReviewPoint from "../common/reviewPoint";
import ItemBox from "./itemBox";
import { useNavigate } from "react-router-dom";

interface RankingItemProps {
  rankingData: any[];
}

const RankingItem: React.FC<RankingItemProps> = ({ rankingData }) => {
  const navigate = useNavigate();

  const handleItemClick = (id: number) => {
    navigate(`/api/item/${id}`);
  };

  return (
    <>
      <RankingItemWrapper>
        {rankingData.length > 0 ? (
          rankingData.map((item, index) => (
            <ItemBox
              key={index}
              item={item}
              rank={index + 1}
              onClick={() => handleItemClick(item.id)}
            />
          ))
        ) : (
          <p>랭킹 정보가 없습니다.</p>
        )}
      </RankingItemWrapper>
    </>
  );
};
export default RankingItem;

const RankingItemWrapper = styled.div`
  width: 100%;
  margin: 0 auto;
  padding: 20px 0;
`;

랭킹 제품을 담는 rankingItem 컴포넌트는 랭킹제품 데이터를 부모 컴포넌트에서 props로 가져와 해당 배열의 길이만큼 map을 이용해 제품box를 만들게 구현했다.

 

import ReviewPoint from "../common/reviewPoint";
import "../../scss/ranking/itemBox.scss";

interface ItemBoxProps {
  item: {
    brand: string;
    itemName: string;
    price: number;
    imageUrl: string;
    reviewPoint: number;
    reviewCount: number;
  };
  rank: number;
  onClick: () => void;
}

const ItemBox: React.FC<ItemBoxProps> = ({ item, rank, onClick }) => {
  return (
    <>
      <div className="rankingItemBox" onClick={onClick}>
        <div className="rankingNum">{rank}</div>
        <div className="rankingInfoBox">
          <div className="rankingImg">
            <img src={item.imageUrl} alt={`${item.itemName} 이미지`} />
          </div>
          <div className="rankingInfo">
            <span className="itemName">
              {item.brand} <span>{item.itemName}</span>
            </span>
            <ReviewPoint />
            <span className="itemPrice">
              정가 <span>{item.price}원</span>
              /50ml
            </span>
          </div>
        </div>
      </div>
    </>
  );
};
export default ItemBox;

itemBox 컴포넌트에선 받아온 제품 데이터가 보일 수 있게 객체값으로 수정해줬다. 이 부분은 서버에서 어떤 데이터를 보내냐에 따라 다시 수정할 예정이다.

그리고 특정 제품을 클릭하면 특정 제품의 상세 페이지로 가기 위해 해당 제품의 id값을 경로에 담아 이동하게 된다.

 

import styled from "styled-components";
import BackHeader from "../components/common/backHeader";
import Lotion from "../img/화장품1.jpg";
import MainFooter from "../components/common/mainFooter";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import axios from "axios";

interface ItemDetails {
  itemName: string;
  price: string;
  volume: string;
  imageUrl: string;
  effect: string;
}

const RankingDetail: React.FC<ItemDetails> = () => {
  const { id } = useParams<{ id: string }>();
  const [itemDetails, setItemDetails] = useState<ItemDetails | null>(null);

  const navigate = useNavigate();

  const handleBackPage = () => {
    navigate(-1);
  };

  useEffect(() => {
    const fetchItemDetails = async () => {
      try {
        const response = await axios.get(`/api/items/${id}`);
        setItemDetails(response.data);
      } catch (error) {
        console.error("Error fetching item details:", error);
      }
    };

    if (id) {
      fetchItemDetails();
    }
  }, [id]);

  if (!itemDetails) {
    return <p>Loading...</p>;
  }

  return (
    <>
      <RankingDetailWrapper>
        <BackHeader onBack={handleBackPage} />
        <DetailItemBox>
          <ItemImg>
            <img src={itemDetails.imageUrl} alt={itemDetails.itemName} />
          </ItemImg>
          <ItemInfo>
            <h3>{itemDetails.itemName}</h3>
            <ItemPrice>
              <span>정가</span>
              <span>
                {itemDetails.price}원 / {itemDetails.volume}ml
              </span>
            </ItemPrice>
            <ItemEffect>제품 효과 박스</ItemEffect>
          </ItemInfo>
        </DetailItemBox>
      </RankingDetailWrapper>
      <MainFooter />
    </>
  );
};
export default RankingDetail;

const RankingDetailWrapper = styled.div`
  width: 100%;
  display: flex;
  flex-direction: column;
`;

const DetailItemBox = styled.div`
  width: 90%;
  margin: 20% auto 0 auto;
`;

const ItemImg = styled.div`
  width: 80%;
  height: auto;
  aspect-ratio: 1/1;
  object-fit: cover;
  margin: 0 auto;
  border: 1px solid #d9d9d9;

  img {
    width: 100%;
    height: auto;
    aspect-ratio: 1/1;
    object-fit: cover;
  }
`;

const ItemInfo = styled.div`
  width: 80%;
  margin: 10% auto 0 auto;
`;

const ItemPrice = styled.div`
  display: flex;
  justify-content: flex-start;

  span {
    &:nth-child(1) {
      font-size: 14px;
      color: #848484;
      margin-right: 30px;
    }

    &:nth-child(2) {
      font-size: 14px;
      color: black;
    }
  }
`;

const ItemEffect = styled.div`
  width: 100%;
  height: 120px;
  border: 1px solid #d9d9d9;
  background-color: #d9d9d9;
  border-radius: 13px;
  margin-top: 10%;
`;

특정 제품 상세 페이지로 이동되면 useParams라는 리액트 훅을 이용해 경로에서 id값을 가져와 서버에게 해당 제품 id에 맞는 데이터를 요청하게 구현했다. 예외처리를 위해 id가 있을때만 api요청이 되게 했고 응답이 올 때까지 로딩 div를 띄우게 했다. 로딩 div는 나중에 UI를 수정할 예정이다.

 

그리고 장바구니가 비었을 때도 어색하게 보이면 안되기 때문에 알맞은 이미지와 제품 정보가 없다는 텍스트를 띄워줬다. 

 

 

드디어 드래그 슬라이드가 작동되는데 아직 원인이 뭔지 정확히는 못 찾았다.

메인 페이지 드래그를 구현하기 전에 랭킹 페이지의 필터에도 비슷한 드래그 슬라이드를 구현했어야 해서 먼저 하게 됐다.

서브 필터의 드래그는 작동이 너무 잘 되서 메인 페이지도 그대로 적용하려 했으나 여전히 작동이 안되더라..

그러던 중 원인일지도 모르는 부분이 div구조와 css에 있었다.

 

import styled from "styled-components";
import BannerBox from "./bannerBox";
import { useEffect, useRef, useState } from "react";

const TopRoutineBox: React.FC = () => {
  const [isDragging, setIsDragging] = useState(false);
  const [startX, setStartX] = useState(0);
  const [scrollLeft, setScrollLeft] = useState(0);
  const topRoutineRef = useRef<HTMLDivElement>(null);

  const handleMouseDown = (e: React.MouseEvent) => {
    setIsDragging(true);
    setStartX(e.pageX - (topRoutineRef.current?.offsetLeft || 0));
    setScrollLeft(topRoutineRef.current?.scrollLeft || 0);
  };

  const handleMouseLeave = () => {
    setIsDragging(false);
  };

  const handleMouseUp = () => {
    setIsDragging(false);
  };

  const handleMouseMove = (e: React.MouseEvent) => {
    if (!isDragging) return;
    e.preventDefault();
    const x = e.pageX - (topRoutineRef.current?.offsetLeft || 0);
    const walk = (x - startX) * 2; // scroll-fast
    if (topRoutineRef.current) {
      topRoutineRef.current.scrollLeft = scrollLeft - walk;
    }
  };

  useEffect(() => {
    const handleMouseUpGlobal = () => {
      setIsDragging(false);
    };

    document.addEventListener("mouseup", handleMouseUpGlobal);
    return () => {
      document.removeEventListener("mouseup", handleMouseUpGlobal);
    };
  }, []);

  return (
    <OutBox
      ref={topRoutineRef}
      onMouseDown={handleMouseDown}
      onMouseLeave={handleMouseLeave}
      onMouseUp={handleMouseUp}
      onMouseMove={handleMouseMove}
    >
      <TopRoutineBanner>
        <BannerBox />
        <BannerBox />
        <BannerBox />
      </TopRoutineBanner>
    </OutBox>
  );
};

export default TopRoutineBox;

const OutBox = styled.div`
  width: 100%;
  overflow-x: auto;
  margin: 20px 0;
  position: relative;

  &::-webkit-scrollbar {
    display: none;
  }

  user-select: none;
`;

const TopRoutineBanner = styled.div`
  width: max-content;
  display: flex;
  justify-content: flex-start;
  cursor: grab;
  position: relative;

  &:active {
    cursor: grabbing;
  }
`;

메인페이지의 드래그를 구현할 top10 루틴 컴포넌트 코드이다. 원인으로 추정되는 건 컴포넌트를 감싸는 부모 컴포넌트에 overflow가 hidden으로 막혀있어서 안되지 않았을까 생각하여 overflow 코드를 없앴다.

그리고 해당 컴포넌트를 감싸는 div가 한 개여서 드래그를 시킬 구조가 안되지 않았을까 했기 때문에 div를 하나더 감싸서 해당 div에 드래그 기능을 넣었다. 이 후 css도 div구조에 맞게 위치를 수정했더니 드래그가 구현이 됐다. 하지만 드래그가 매끄럽지 않아서 자꾸 어딘가에 걸려 제대로 작동이 안되었기 때문에 해당 마우스가 div 밖을 벗어나면 그랩이 풀릴 수 있게 했다.

진짜 옛날에 코딩하던 개발자분들은 지피티 없이 어떻게 살았을까 리스펙하게된다. 

 

 

728x90

'개발 TIL' 카테고리의 다른 글

24.09.19 day67 작업 일지  (4) 2024.09.19
24.09.13 day66 작업 일지  (0) 2024.09.13
24.09.10 day64 작업 일지  (0) 2024.09.10
24.09.09 day63 작업 일지  (0) 2024.09.09
24.09.06 day62 작업 일지  (2) 2024.09.06