2차 프로젝트 - ✈️myfaketrip

더미벨·2022년 7월 24일
1

📍 2차 프로젝트 소개

  • 팀명: my fake trip
  • 클론한 웹사이트: 마이리얼트립
  • 프로젝트 기간: 2022.07.04 ~ 2022.07.15
  • 팀원: Frontend 4명 / Backend 2명


이번 프로젝트는 마이리얼트립 웹사이트를 클론하는 방식으로 진행되었다.

기존의 웹사이트에는 항공권 · 투어/티켓 · 국내숙소 · 해외숙소 등 다양한 카테고리가 있었지만, 우리는 국내숙소 페이지를 메인으로 잡고 진행하게 되었다.

나는 그 중에서도 지역, 날짜, 인원수를 선택하면 넘어가게 되는 검색리스트 페이지를 구현하게 되었다.


📍 검색리스트 - 기능 구현 목표

  • 검색창 내 지역/날짜/인원수 별 모달창 구현 -> 검색 결과에 따른 필터링 기능
  • 페이지네이션
  • 지도 API -> 검색된 숙소들의 위치에 가격으로 마커가 찍히도록
  • 숙박업소 종류별/등급별/가격별/시설별 필터링 기능
  • 추천순/후기순/가격순 필터링



1. 검색창 구현

1) 지역 검색

input창이 onFocus 일 때 모달창이 열리도록 구현.

  • boolean타입의 state를 만들고 onFocus일 때 값을 true로, onBlur일 때 false로 변경
  • 입력된 input값 state에 저장해주기.
const [isLocation, setIsLocation] = useState(false);
const [locationInput, setLocationInput] = useState("");

const openLocation = () => {
    setIsLocation(true);
  };
  const closeLocation = () => {
    setIsLocation(false);
  };
<s.LocationInput
                  onChange={e => {
                    e.preventDefault();
                    setLocationInput(e.target.value);
                  }}
                  onFocus={openLocation}
                  onBlur={closeLocation}
                />

  • 삼항연산자를 이용하여 state값이 true일 때 모달창이 열리도록 구현
 {isLocation && (
              <LocationModal
                locationInput={locationInput}
                setLocationInput={setLocationInput}
              />
            )}

  • 지역명 또는 호텔명을 검색할 때마다 검색한 내용만 모달창에 띄우기 위해 filter함수를 사용해주었다.
  const sortedList = searchList.filter(hotels => {
    return (
      hotels.address.includes(locationInput) ||
      hotels.name.includes(locationInput)
    );
  });

  • 모달창 내에 sorting 된 호텔 데이터를 map함수를 이용해 나열하고, overflow-y: hidden;을 통해 모달창 바깥으로 나열되는 데이터는 숨겨주었다.
{sortedList.map(list => {
          return (
            <LocationListBox key={list.id}>
              <HotelIcon icon={faHotel} size="2x" />
              <div
                onClick={() => {
                  setLocationInput(list.name);
                }}
              >
                <HotelName>{list.name}</HotelName>
                <HotelLocation>{list.address}</HotelLocation>
              </div>
            </LocationListBox>
          );
        })}

2) 캘린더 모달창

캘린더는 ant-design 라이브러리를 사용하여 구현했다.
( ant-design 라이브러리 설치 및 사용 방법 👈 참고! )

라이브러리 사용은 이번이 처음이었는데 편리해보이면서도 기능 하나, 스타일 값 하나 바꾸는 게 생각보다 복잡해서 애를 먹었다..^^;

캘린더 라이브러리는 애초에 날짜 input창을 누르면 아래로 캘린더모달이 열리게 끔 구현되어 있어서 style값만 약간 바꾸고 입력한 값을 배열의 형태로 state에 저장해주었다.

const [date, setDate] = useState();

 <StyledDatePicker picker="date" onChange={getDateValue} />
   
const StyledDatePicker = styled(DatePicker.RangePicker)`
  position: absolute;
  left: 50px;
  top: 10px;
`;

이번 프로젝트에서는 scss 대신 styled-component를 사용했는데, 이렇게 하니 캘린더 모달은 style 속성 변경이 가능하지만 input창 style값이 도저히 변경이 안됐다. 아마 DatePicker.RangePicker 태그 바깥에 input창이 있는듯 했다...

그래서 결국은 scss를 사용하게 되었다. 개발자도구를 열어 input창의 classname을 확인 후 하나씩 style값을 지정해주었다.

.searchContainer {
  .ant-picker.ant-picker {
    border: 0 !important;
    padding: 4px;
  }

  .ant-picker:focus {
    outline: none;
  }

  .ant-rate {
    color: #51abf3;
    margin-top: 20px;
  }
  .ant-rate svg {
    width: 35px;
    height: 35px;
  }

  .ant-rate path {
    width: 35px;
    height: 35px;
  }

  .ant-picker-input input {
    font-size: 14px;
    font-weight: bold;
  }

  .ant-picker-suffix {
    display: none;
  }
}

3) 인원수 선택 모달창

인풋창 클릭시 모달창이 열리고 닫히는 건 location 모달과 똑같이 구현했고, 모달 내에서 +-버튼을 클릭할 때 어른과 아이의 수가 따로따로 변경되고 상단 Input창에서는 둘의 합계가 보이도록 구현하는 것에 집중했다.

  const plusAdult = () => {
    setCountAdult(countAdult + 1);
  };
  const minusAdult = () => {
    if (countAdult > 1) {
      setCountAdult(countAdult - 1);
    }
  };
  const plusChild = () => {
    setCountChild(countChild + 1);
  };
  const minusChild = () => {
    if (countChild > 0) {
      setCountChild(countChild - 1);
    }
  };

 <s.SearchBox onClick={openHeadcount} onBlur={closeHeadcount}>
              <s.Number>{countAdult + countChild}</s.Number>
            </s.SearchBox>

{isHeadcount && (
              <HeadcountModal
                setIsHeadcount={setIsHeadcount}
                countAdult={countAdult}
                setCountAdult={setCountAdult}
                countChild={countChild}
                setCountChild={setCountChild}
              />
            )}



2. 페이지네이션

const LIMIT = 40;

const updateOffset = buttonIndex => {
    const offset = LIMIT * buttonIndex;
    const queryString = `?limit=${LIMIT}&offset=${offset}`;

    navigate(queryString);
  };
fetch(
      `${IP}/products?${
        location.search.split("?")[1] || `limit=${LIMIT}&offset=0`
      }`
    )

useLocation과 useNavigate함수를 이용해서 버튼을 클릭할 때마다 offset값이 변경되도록 구현했다.
또한 클릭된 버튼만 색상이 변경되도록 하기 위해 clicked와 id값이 일치하는지를 확인하고, props와 삼항연산자를 이용해 style속성이 변경되도록 구현했다.

export default function Buttons({ updateOffset, id, clicked, setClicked }) {
  const clickButton = id => {
    setClicked(id);
  };

  return (
    <Button
      onClick={e => {
        clickButton(id);
        updateOffset(id - 1);
      }}
      primary={clicked === id}
    >
      {id}
    </Button>
  );
}
background-color: ${props => (props.primary ? "#52ABF3" : "white")};
color: ${props => (props.primary ? "white" : "#52ABF3")};

그런...데...

다른 querystring을 연결하는 과정에서 페이지네이션과 같이 작동하게 하는 방법을 못찾았다. 처음에는 limit값을 10으로 설정해서 한 페이지당 숙소가 10개씩 보이도록 했는데, 숙소 카테고리, 가격, 등급 등의 조건을 설정하고 필터링 된 화면에서 다시 페이지버튼을 누르면 필터링 된 값이 모두 사라져버린다...

이런저런 시도를 해보았으나 시간의 부족으로 결국 페이지네이션기능은 포기하고, limit값을 40으로 설정해 모든 데이터가 다 1번페이지에 나열되게끔 만들었다.. ㅎ ㅏ아..ㅠ


3. 각 조건별 필터링

이번에는 프론트에서 데이터를 필터링해 보여주는 것이 아니라 백엔드에서 이미 필터링 된 데이터를 query string과 end point를 이용해 보여주는 방식으로 진행했다.

따라서 값이 계속 변화하는 부분들을 변수로 저장하고, useNavigate와 useLocation을 사용하여 url주소를 변경시켜주었다.


필터링 되어야 하는 조건들은 아래와 같다.

  • 상단 검색창 - 지역(호텔), 투숙 날짜(기간), 인원수
  • 숙소 카테고리(호텔, 펜션, 게하 등)
  • 가격 -range bar 라이브러리로 구현
  • 호텔 등급 - rate 라이브러리로 구현
  • 호텔 내부 시설(수영장, 루프탑, 피트니스 등) - 체크박스로 구현

1) 숙소 카테고리

카테고리 버튼도 한번에 하나만 클릭되고 색상이 변경되도록 하기 위해 페이지네이션 버튼과 똑같은 로직으로 구현해주었다. 그리고 클릭된 부분을 state에 저장해주었다.

2) 가격

처음에는 html의 <input type = "range" />를 이용해 구현했으나, ant-design 내에 slider 라이브러리가 있는 걸 알고 그걸 사용했다.

  • 가격 범위가 0원부터 100만원이었기 때문에 state의 초기값을 [0, 1000000]으로 배열에 담아주었다.
  • 레인지바가 양방향으로 이동되도록 하기 위해 range={true}로 설정해주었다.
  • slider의 범위가 바뀌고 마우스 커서를 놓을 때 이벤트가 실행되도록 하기 위해 onAfterChange에서 price값이 배열의 형태로 담기도록했다.
  • 가격이 만원 단위로 움직이므로 step값은 10000으로 설정해주었다.
  • 가격이 변동될 때마다 변하는 텍스트는 숫자 세자리마다 쉼표가 찍히도록 하기 위해 정규표현식을 이용해주었다.
 const [price, setPrice] = useState([0, 1000000]);

<s.FilterText>
              {price[0]
                .toString()
                .replace(/\B(?<!\.\d*)(?=(\d{3})+(?!\d))/g, ",")}~
              {price[1]
                .toString()
                .replace(/\B(?<!\.\d*)(?=(\d{3})+(?!\d))/g, ",")}
              {price[1] === 1000000 ? "원 이상" : "원"}
            </s.FilterText>

            <Slider
              range={true}
              values={price}
              max={1000000}
              step={10000}
              onAfterChange={price => {
                setPrice(price);
              }}
            />

3) 호텔 등급

마찬가지로 라이브러리를 사용했고, 클릭된 부분이 state에 저장되도록 했다.

const [rates, setRates] = useState(0);

 <Rate onChange={getRates} />

4) 호텔 내부 시설

  • FACILITIES라는 상수데이터를 만들고 map함수를 이용해 나열
  • 체크된 부분들을 ischecked라는 state에 배열의 형태로 담음.
 const [ischecked, setIsChecked] = useState([]);

 <s.FilterTitle>시설</s.FilterTitle>
            {FACILITIES.map(el => {
              return (
                <Facilities
                  key={el.id}
                  name={el.facility}
                  // checked={el.name}
                  ischecked={ischecked}
                  setIsChecked={setIsChecked}
                />
              );
            })}



5) query string 이용해 백엔드 데이터 가져오기

🪄 백엔드에서 넘겨준 end point

  • 지역/호텔: search
  • 입실 날짜: start_date 퇴실 날짜: end_date
  • 인원수: guest
  • 숙소 카테고리: category
  • 최소 가격: min_price 최대 가격: max_price
  • 호텔 등급: grade
  • 시설: amenities

모든 엔드포인트는 &로 연결한다.

  • 시설의 경우 체크표시된 값들이 배열로 담기는데 &로 연결된 string의 형태로 만들어야 하기 때문에 map함수를 이용해 요소 앞에 &를 붙여주고 join함수를 이용해 하나의 string으로 만들어주었다.
  let amenities = ischecked.map(el => {
    return `&amenity=${el}`;
  });

  amenities = amenities.join("");

  • filter함수 내에 navigate를 이용해 query string을 담아주고 검색버튼을 누를 때마다 filter함수가 실행되도록 구현.
  • category 부분은 전체를 선택했을 때, 혹은 아무것도 선택이 되지 않은 상태에서는 아예 공백이어야 하기 때문에 삼항연산자를 이용.
  • 체크박스도 아무것도 클릭되지 않았을 때는 endpoint가 없기 때문에 배열의 길이가 0일 때 공백이 되도록 삼항연산자를 이용해 구현했다.
const filter = () => {
    navigate(
      `?search=${locationInput}&start_date=${date[0]}&end_date=${
        date[1]
      }&guest=${countAdult + countChild}${
        selectedCategory && selectedCategory !== `전체`
          ? `&category=${selectedCategory}`
          : ``
      }&min_price=${price[0]}&max_price=${price[1]}${
        rates ? `&grade=${rates}` : ``
      }${ischecked.length !== 0 ? amenities : ``}`
    );
  };




✨ 느낀점

처음 목표한 부분의 70%정도를 달성한 것 같다.
이번 프로젝트에서 styled-component와 리액트 라이브러리를 처음 사용해보았는데 생각보다 어려워 시간이 많이 걸렸다.

또 백엔드에서 이미 다 필터링 한 데이터를 넘겨주기 때문에 프론트에서는 할 일이 많이 없을 거라 생각했는데, 계속 변화하는 값을 state에 저장하고 다시 query string으로 구현하는 과정에서 많이 헤맸다.

그래도 1차 때랑 비교하면 UI 구성도 하루이틀만에 끝내고 나머지는 온전히 기능구현하는 데에 집중한 것 같다. 또 같은 기능을 구현할 때도 어떤 식으로 코드를 짜야 효율적인가에 대한 고민을 많이 한 것 같다.

Map API를 꼭 써보고 싶었는데 시간의 제약으로 구현해보지 못한 게 너무너무 아쉽다..ㅠㅠ API를 불러오는 것까지는 성공했지만 해당되는 데이터들의 위치를 전부 마커로 찍는 부분을 구현하지 못했다.. 백엔드에서 위도경도 데이터들도 모두 넘겨주셨는데.. ㅎ ㅏ아..🥲 이부분은 개인적으로 공부해서 꼭 만들어보고 싶다!

profile
프론트엔드 개발자👩‍💻

0개의 댓글