2차 프로젝트 - 카페 홈페이지 클론 (공차) 메인페이지, 매장 검색, 리뷰 작성 (2)_상세코드

이고운·2022년 10월 4일
0

2차 팀프로젝트

목록 보기
2/3

내가 구현한 부분은 크게 메인메이지, 매장 검색 페이지, 상세페이지 내 리뷰 작성부분으로 나뉜다.
처음에는 백엔드 담당 인원 부족으로 매장 검색 페이지를 mock data로 가져와서 뿌려줄까 했지만
다행히 백엔드 담당자분이 매장 검색 API 통신하는 부분도 구현해주셔서 백엔드에서 통신해 데이터를 가져올 수 있었다.
내가 각 페이지에서 구현하고자하는 기능은 아래와 같다.

1. 필요 기능 구현 정리

1) 메인페이지

사실상 메인페이지에 기능적으로 구현하는 부분은 몇가지 없다.

  • 메인페이지 상단에 슬라이드 구현 (일정 시간 지나면 자동으로 슬라이드 넘김)
  • 매장 검색 input 창과 버튼이 있는데 해당 부분에서 검색 키워드를 입력하면 그 데이터가
    매장 검색 페이지로 넘어가서 해당 검색 키워드를 포함한 매장이름의 검색 결과가 나옴 ⭐️
  • 나머지 부분은 이미지 삽입, 해당 이미지 클릭하면 관련 페이지로 이동

2) 매장 검색 페이지

이번 프로젝트에서 비중을 제일 많이 차지한 부분

  • 1차로 시/도 select box에서 지역을 분리함
  • 2차로 input창에 검색 키워드를 입력하면 해당 키워드가 포함된 매장이름의 리스트가 조회됨.
  • 매장 리스트를 클릭하면 모달창 생성
  • 모달창 안에는 지도 API를 이용해 매장 위치 띄우고 하단에는 매장 이름, 주소 기재
  • 매장 리스트는 Axios로 데이터 백에서 받아옴.

3) 상세페이지 내 리뷰 작성 기능

  • 기존 공차페이지에는 없는 기능
  • 한줄평으로 음료 후기를 남길 수 있음. 이때 글로만 후기를 남기면 심심한 것 같아, 별점 기능 추가
  • 별점은 별 하나당 1점으로 점수 생성
  • 본인계정 등록, 삭제 가능, 구매한 사람만 등록 가능

2. 상세 코드 정리

1) 메인페이지

사실 메인페이지는 크게 기능 구현 진행한 곳이 없다.
처음에 슬라이드 삽입하는 것 외에 UI만 구현했었다. 매장 검색 칸에 검색기능 넣고 매장 검색 메뉴로 이동하여 결과 나오는 기능은 제일 마지막에 다른 팀원분 도움을 받아 완성했다.
먼저 main.tsx store.tsx 파일 둘 다 같은 상태값을 쓸려면 그보다 상위 컴포넌트에서 props로 넘겨줘야했다. 때문에 app.tsx에서 main과 store 페이지에 상태값을 넘겨줬다.

<App.tsx>
const App = () => {
  const [search, setSearch] = useState('');

  return (
    <>
      <Nav setSearch={setSearch} />
      {isLogin && !isMatch && <MatchModal />}
      <Routes>
        <Route path='/' element={<Main search={search} setSearch={setSearch} />} />
        <Route path='/store' element={<Store search={search} setSearch={setSearch} />} />
      </Routes>
        

그리고 각각 main과 store 해당 상태값을 사용했다.
사실 처음에는 상위컴포넌트에서 넘길 생각을 못하고 메인페이지에서 useStore를 사용하여 inputvalue를 스토어 페이지로 가져갔다.
이 때 콘솔 찍어보니 메인페이지에서 검색한 키워드가 스토어페이지로 넘어가긴했는데 그 이후 키워드로 검색이 안됐다....그런데 이렇게 상위컴포넌트에서 상태값 넘긴 것도 제일 좋은 방법은 아닌 것 같다. 일단 메인페이지 안에 검색창을 컴포넌트로 뺐기 때문에 사실 컴포넌트 넘긴 것만 하더라도 app-main-search-store 4단계나 되기 때문에 번거로운 작업이다... 이래서 전역 관리가 필요한 것 같다고 생각했다... 그런데 전역으로 상태값 저장하는 방법은 아직 잘 모르겠어서, 이부분은 더 공부를 해야할 것 같다는 생각이 들었다.

<Main.tsx>
interface MainProps {
  search: string;
  setSearch: React.Dispatch<React.SetStateAction<string>>;
}

const Main = ({ search, setSearch }: MainProps) => {
   <Search search={search} setSearch={setSearch}/>

이런식으로 prop 넘겨줌. 이때 타입스크립트 사용으로 props 타입도 지정해줘야함.

<Search.tsx>
nterface SearchProps {
  search: string;
  setSearch: React.Dispatch<React.SetStateAction<string>>;
}
const Search = ({ search, setSearch }: SearchProps) => {
  const inputRef = useRef<HTMLInputElement>(null);
  const [inputValue, setInputValue] = useState('');
  
  const inputHandler: React.FormEventHandler<HTMLInputElement> = e => {
    if (e.target instanceof HTMLInputElement) {
      setInputValue(e.target.value);
    }
  };
const submitHandler: React.FormEventHandler<HTMLFormElement> = e => {
    e.preventDefault();

    if (!inputValue) {
      alert('매장명을 입력해주세요');
    } else {
      setSearch(inputValue);
      navigate('/store');
    }
  };

return() 안에 위에 이벤트 핸들러 onSubmit, onChange로 넘겨줌.
inputHandler에 inputValue를 저장해줌 그리고 검색 버튼 submitHandler에 저장해준 inputValue를 onSubmit으로 제출했다.

<Store.tsx>
interface StoreProps {
  search: string;
  setSearch: React.Dispatch<React.SetStateAction<string>>;
}

const Store = ({ search, setSearch }: StoreProps) => {

useEffect(() => {
    clearTimeout(debounce.current);
    const loadStoreList = async () => {
      setLoading(true);
      try {
        const { data } = await axios.get<Storetype[]>('http://localhost:8000/shops');
        const filteredData = data.filter(store => store.name.includes(search));
//통신해서 가져오는 데이터를 props로 넘겨온 search값이 포함된 글자의 스토어 이름을 리스트로 조회해줌.
        setAddressList(filteredData);
        setLoading(false);
      } catch (error) {
        console.log(error);
      }
    };
    debounce.current = setTimeout(loadStoreList, 500);
 //debounce(clearTimeout, setTimeout)는 해당 이벤트가 발생하고 나중에 매장 검색 페이지에 들어왔을 때 초기화 시키기 위해 넣었다.
 
  }, [search]);
  
  <form>
      <input type='text' ref={inputRef} placeholder='매장명을 검색해 주세요' onChange={e => setSearch(e.target.value)} />
      <button onClick={submitHandler}>
         <BiSearch />
      </button>
  </form>

2) 스토어 페이지

위에 메인페이지 기능이랑 연결되어있어, 코드를 이미 기재한 것도 있지만
사실상 제일 많은 시간을 썼다. 저 메인페이지에서 스토어페이지로 검색결과 넘기는 기능도 그렇고 지도 삽입하는 것도 원래 카카오 지도 API로 삽입했으나
모바일로 접속 시에 지도가 나오지 않았다. 오류 코드 검색하고 구글링도 열심히 했는데 해결을 못해서 결국 네이버 지도 API로 변경하여 가져왔다...
또 원래 백엔드 팀원 부족으로 그냥 mock data로 매장리스트 가져오려고 했는데 다행히 백엔트 팀원분이 프로젝트 진도도 빠르시고 잘하시는 분이라 axios 통신하여 백엔드에서 받아올 수 있었다.
내 스토어페이지는 필터링 기능이랑 해당 매장 클릭시 모달창으로 위치 지도랑 매장 이름, 주소가 나오게 되어있다.

<Store.tsx>
// 매장 리스트에 들어가는 항목들 타입을 배열로 지정해 줌
export interface Storetype {
  id: number;
  name: string;
  address: string;
  latitude: number;
  longitude: number;
}

const Store = ({ search, setSearch }: StoreProps) => {
  const [selectedOption, setSelectedOption] = useState<string>('');
  const [addressList, setAddressList] = useState<Storetype[]>([]);
  const [address, setAddress] = useState<string>('');
  const [name, setName] = useState<string>('');
  const [latitude, setLatitude] = useState<number>(0);
  const [longitude, setLongitude] = useState<number>(0);
  const [value, setValue] = useState<string>('');
  const [modal, setModal] = useState<boolean>(false);
  const inputRef = useRef<HTMLInputElement>(null);
  const [loading, setLoading] = useState(true);
  const debounce = useRef<NodeJS.Timeout>();

//select box 보기 부분,, 전 지역을 넣으면 좋겠지만 백엔드에서 매장 데이터 넣는 것도 한계가 있어 대표 도시 몇개만 넣음.

  const addresses = ['시,도', '서울특별시', '부산광역시', '대구광역시', '인천광역시', '경기도'];

//매장 리스트를 가져오는 axios 통신 함수 부분 useEffect를 이용해서 렌더링될 때 가져오게 함

//clearTimeout함수는 메인에서 넘어온 키워드 검색을 초기화시키기 위함.
  useEffect(() => {
    clearTimeout(debounce.current);

    const loadStoreList = async () => {
      setLoading(true);
      try {
        const { data } = await axios.get<Storetype[]>('http://localhost:8000/shops');
//여기서 filter함수로 메인창에서 검색한 키워드가 포함된 매장이름을 가져오게 함
        const filteredData = data.filter(store => store.name.includes(search));
        setAddressList(filteredData);
        setLoading(false);
      } catch (error) {
        console.log(error);
      }
    };

    debounce.current = setTimeout(loadStoreList, 500);
  }, [search]);
  
  //리스트 클릭할 때 상세 내용 나오는 모달창 열어줄 것임.
  매장이름, 주소, 위도 경도 넘겨줌
  const onClickModal = useCallback(
    (add: { id: number; name: string; address: string; latitude: number; longitude: number }) => {
      setModal(!modal);
      setName(add.name);
      setAddress(add.address);
      setLatitude(add.latitude);
      setLongitude(add.longitude);
    },
    [modal]
  );

// select box에 있는 키워드 필터를 위해 value값 저장
  const selectChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
    const value = event.target.value;
    setSelectedOption(value);
    setAddressList(addressList);
  };

//input창에 키워드 검색 후 필터를 위해 value값 저장
  const submitHandler: React.FormEventHandler<HTMLButtonElement> = e => {
    e.preventDefault();

    if (inputRef.current) {
      setValue(inputRef.current.value);
      inputRef.current.value = '';
    }
  };

  return (
    <div>
      <StyledHeader>
        <h1>STORE</h1>
        <p>간편하게 공차의 매장을 검색해보세요.</p>
      </StyledHeader>
      <StyledSearch>
        <div className='container'>
          <div className='search'>
            <select onChange={selectChange} value={selectedOption}>
              {addresses.map(item => (
                <option value={item} key={item}>
                  {item}
                </option>
              ))}
            </select>
            <form>
              <input type='text' ref={inputRef} placeholder='매장명을 검색해 주세요' onChange={e => setSearch(e.target.value)} />
              <button onClick={submitHandler}>
                <BiSearch />
              </button>
            </form>
          </div>
        </div>
      </StyledSearch>
      <StyledList>
        <>
// 1차로 select box에 있는 키워드를 확인함. 셀렉트박스에 있는 도시 이름이랑 주소를 slice하여 앞에 2개를 자른 단어랑 비교했을 때 같은 값을 reture.        
          {!loading &&
            addressList
              .filter(val => {
                if (selectedOption == '시,도') {
                  return val;
                }
                if (val.address.slice(0, 2).includes(selectedOption.slice(0, 2))) {
                  return val;
                }
              })
//2차로 input창에 있는 검색 키워드를 확인 함. 검색 키워드가 포함된 매장 이름을 가진 매장 리스트를 return 함.              
              .filter(val => {
                if (value == ' ') {
                  return val;
                }
                if (val.name.toLowerCase().includes(value.toLowerCase())) {
                  return val;
                }
              })
 //리스트는 map 함수를 이용하여 가져옴. 클릭시 모달창 팝업             
              .map((add, i) => (
                <li onClick={() => onClickModal(add)} key={add.id}>
                  <h4>{add.name}</h4>
                  <p>{add.address}</p>
                </li>
              ))}
          {modal && <Modal latitude={latitude} longitude={longitude} setModal={setModal} name={name} address={address} addressList={addressList} onClickModal={onClickModal}></Modal>}
        </>
      </StyledList>
 // 통신에러로 데이터 못가져올 때를 대비해 스켈레톤 처리함.     
      {loading && <StoreSkeleton />}
    </div>
  );
};
<Location.tsx>  // 네이버 지도 들어가는 곳
import { useEffect } from 'react';

interface LocationDefaultType {
  latitude: number;
  longitude: number;
}
const Location = ({ latitude, longitude }: LocationDefaultType) => {
  useEffect(() => {
    let navermap = null;
    const initMap = () => {
      const navermap = new naver.maps.Map('map', {
        center: new naver.maps.LatLng(latitude, longitude),
        zoom: 13,
      });

      const marker = new naver.maps.Marker({
        position: new naver.maps.LatLng(latitude, longitude), //Marker 추가, 좌표에 마커가 찍힌다.
        map: navermap,
        icon: {
          url: `<img alt="marker" src={vectorIcon} />`,
        },
      });
    };
    initMap();
  }, []);

  return (
    <div>
      <div id='map' style={{ width: '100%', height: '200px' }}></div>
    </div>
  );
};

export default Location;

해당 코드는 네이버 지도 API 받는 개발자 사이트에서 가져온 것임.
저 위도, 경도 부분만 수정함. 여기서 위도와 경도 데이터는 백엔드에서 가져옴
원래 lat, lng 였는데 백엔드랑 통일시킴,, 중간에 그냥 lat, lng으로 했다가 데이터 안들어와서 확인해보니 백엔드랑 코드가 달랐다. 이것만봐도 백엔드랑 커뮤니케이션이 얼마나 중요한지 알 수있음.. 이부분 통신여부 자체가 중간에 결정되어서 그렇긴 한데 앞으로도 통신할 때 백엔드랑 키워드도 잘 맞춰야겠다는 생각이 들었다.

3)리뷰 구현

이부분은 통신하는 것이 어려웠다. 위에서는 단순하게 get으로 데이터만 가져오는 것이었다면 리뷰 통신시에는 계정도 확인하고 get, post, delete를 사용해서 통신했어야했다. 아마 수정까지 있었으면 patch까지 있었겠지만
해당 리뷰 부분 자체가 기존 사이트에는 없는 기능이라 구현에 초점을 두었다.
원래 해당 리뷰 기능이 음료 상세페이지에 들어가는 부분이라 해당 페이지 담당하시는 분이 하기로 했었는데, 그분이 담당하시는게 워낙 많기도 하고 나도
리뷰페이지 한번 구현 경험하고 싶어서 내가 진행하기로 했다.
그런데 통신하는 axios 함수에 타입 지정해주는 것도 그렇고 그 팀원분의 도움을 많이 받아서 완성한 부분이라,, 내가 100% 구현한 것은 아니다 ㅎㅎ..
그런데 덕분에 많이 배우고 기능 퀄리티도 좋아졌다!

일단 내가 구현할 때는 한줄평 처럼 간단하게 리뷰 기능을 넣고 싶었고
그렇게만 하면 좀 심심하고 별도의 기능을 넣고 싶었다. 그래서 별점 기능을 추가했는데 별점이 마우스 클릭 위치에 따라 세부적으로 소숫점까지 산정되면 좋았겠지만 어차피 리뷰 평점을 평균치로 낼 건 아니었어서 단순하게 별 1개당 1점으로 산정했다.

<Review.tsx>
const Reviews = () => {
  const { token } = useStore();
  const { id } = useParams();
  const [inputValue, setInputValue] = useState<string>('');
  const [startScore, setStarScore] = useState<number>(0);
  const [reviewList, setReviewList] = useState<Review[]>();
  const [errorModal, setErrorModal] = useState(false);
  const [message, setErrorMessage] = useState('');
  const [disabled, setDisabled] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);


//get으로 사용자 정보 가져오는 부분
  useEffect(() => {
    (async () => {
      try {
        const { data: reviewRes } = await axios.get<ReviewRes>(`http://localhost:8000/beverages/review/${id}`);

        setReviewList(reviewRes.reviewData);
      } catch (error) {
        console.log(error);
        setErrorModal(true);
      }
    })();
  }, []);

//post로 리뷰 생성하는 부분, token을 가져와서 본인인지 확인한다.
  const createReviewHandler = async () => {
    if (!id || !inputValue) return;

    try {
      setDisabled(true);
      await axios.post<CreateReviewRes, AxiosResponse<CreateReviewRes>, CreateReviewReq>(
        `http://localhost:8000/beverages/review/${id}`,
        {
          content: inputValue,
          score: startScore + 1,
        },
        {
          headers: {
            Authorization: token,
          },
        }
      );
      
 //기본 data로 받는 것을 data:reviewRes로 적으면서 reviewRes로 지정해준 것임.     
      const { data: reviewRes } = await axios.get<ReviewRes>(`http://localhost:8000/beverages/review/${id}`);
      setReviewList(reviewRes.reviewData);
      setInputValue('');
      inputRef.current && (inputRef.current.value = '');
      setDisabled(false);
//구매한 사람이 아니면 모달창이 뜨면서 구매한사람만 등록할 수 있다고 alert 발생함. (리뷰 작성 불가능)     
    } catch (error) {
      setErrorModal(true);
      console.log(error);
      setDisabled(false);
      setErrorMessage('구매한 사람만 등록할 수 있습니다.');
      setInputValue('');
      inputRef.current && (inputRef.current.value = '');
    }
  };
// 리뷰 삭제하는 부분, delete함수 사용, 역시 본인이 작성한 리뷰만 삭제 가능 (본인 아닐시에 모달창 생성하여 실패 메세지 띄움)
  const removeReviewHandler = async (review_id: number) => {
    setDisabled(true);
    try {
      await axios.delete(`http://localhost:8000/beverages/review/${review_id}`, {
        headers: {
          Authorization: token,
        },
      });
      const { data: reviewRes } = await axios.get<ReviewRes>(`http://localhost:8000/beverages/review/${id}`);
      setReviewList(reviewRes.reviewData);
      setDisabled(false);
    } catch (error) {
      setErrorModal(true);
      setErrorMessage('실패.');
      console.log(error);
      setDisabled(false);
    }
  };

  return (
    <>
      {errorModal && <ErrorModal errorMessage={message} errorModal={errorModal} setErrorModal={setErrorModal} />}
      <Container>
        <div className='title'>
          <h1>리뷰</h1>
        </div>
        <div className='list'>
//여기서 reviewList다음에 ?를 적은 이유는 처음에 그냥 reviewList.map으로 작성시에 타입오류가 떴음. (Object is possibly 'undefined') 검색해보니 ?를 넣어서 옵셔널 체이닝을 사용하라함. 타입스크립트가 판단하기에 저 배열이 비어있을 수도 있다고 생각한다함.여튼 ?를 넣어서 해결함.        
          {reviewList?.map(data => {
            return (
              <li key={data.id}>
                <span className='nickname'>{data.nickname || '익명'} </span>
     //위에 통신 data안에 넣어준 score, content 불러옴.           
                <span className='score'>{data.score}점</span>
                <div>
                  <span className='content'>{data.content}&nbsp;</span>
                </div>
                <span className='date'>
                  {new Date(data.created_at).getFullYear()}년 {new Date(data.created_at).getMonth() + 1}월 {new Date(data.created_at).getDate()}일 {new Date(data.created_at).getHours()}시 {new Date(data.created_at).getMinutes()}분
                </span>
                <form onSubmit={e => e.preventDefault()}>
                  <button className='deleteButton' onClick={() => removeReviewHandler(data.id)} disabled={disabled}>
                    {disabled ? <Spinner /> : '삭제'}
                  </button>
                </form>
              </li>
            );
          })}
        </div>
        <div className='inputContainer'>
          <span>별점 및 리뷰를 입력해주세요.</span>
          <div className='box'>
// 별클릭하면 점수로 변환하여 저장함.          
            <Stars>
              {[...Array(5).keys()].map(num => (
                <ImStarFull key={num} onClick={() => setStarScore(num)} className={startScore >= num ? 'clicked' : ''} size='5vw' />
              ))}
            </Stars>
          </div>
          <div className='reviewInput'>
            <form onSubmit={e => e.preventDefault()}>
              <div className='inputBox'>
                <input type='text' placeholder='리뷰를 입력하세요' onChange={e => setInputValue(e.target.value)} ref={inputRef} />
                <button onClick={createReviewHandler} disabled={disabled}>
                  {disabled ? <Spinner /> : '확인'}
                </button>
              </div>
            </form>
          </div>
        </div>
      </Container>
    </>
  );
};

export default Reviews;

<수정 전>
별점 부분을 처음에는 다르게 구현했었다. 아래와 같이 점수부분을 아예 배열로 선언하고 별 클릭 여부를 boolean으로 처리하여 true 갯수에 따라 점수가 나오도록 구현했었다.

const Reviews = () => {
  const { addCartHandler, additinalOption, cartDisabled, errorMessage, errorModal, id, info, isLogin, loading, minusHandler, option, setErrorMessage, setErrorModal, setOption, totalOption, token } = useOption();
  const [inputValue, setInputValue] = useState<string>('');
  const [startScore, setStarScore] = useState<number>(0);
  const [nickname, setNickname] = useState<string>('');
  const [starColor, setStarcolor] = useState<string>('gray');
  const [reviewList, setReviewList] = useState<Review[]>();
  const [clicked, setClicked] = useState<boolean[]>([false, false, false, false, false]);
  const array = [0, 1, 2, 3, 4];

  //별클릭에 따라 점수 구현
  const handleStarClick = (index: any) => {
    const clickStates = [...clicked];
    for (let i = 0; i < 5; i++) {
      clickStates[i] = i <= index ? true : false;
    }
    setClicked(clickStates);
    setStarcolor('red');
  };
  
   //리뷰 포스트
  const createReviewHandler = async (e: any) => {
    if (!id || !inputValue) return;
    e.preventDefault();
    const score = clicked.filter(Boolean).length;
    const newComment = {
      id: Number(id),
      nickname: nickname,
      score: score,
      content: inputValue,
      created_at: new Date().toLocaleString(),
    };

    try {
      await axios.post<CreateReviewRes, AxiosResponse<CreateReviewRes>, CreateReviewReq>(
        // './data/mockreviews.json',
        `http://localhost:8000/beverages/review/${id}`,
        {
          content: inputValue,
          score: score,
        },
        {
          headers: {
            Authorization: token,
          },
        }
      );
      const { data: reviewRes } = await axios.get<ReviewRes>(`http://localhost:8000/beverages/review/${id}`);
      setReviewList(reviewRes.reviewData);
      setInputValue('');
    } catch (error) {
      console.log(error);
    }
  };
  --
  <Stars>
    {array.map(el => {
              return <ImStarFull key={el} onClick={() => handleStarClick(el)} />;
            })}
  </Stars>

그리고 별 클릭할때 마다 배열에 있는 점수를 map으로 가져옴.

이렇게 구현했을 때도 별점 클릭에 따라 점수가 잘들어왔었다.
그런데 별점클릭하고 한줄평을 적으러 input박스에 마우스를 대면
별점 클릭해서 변경되었던 별 색상이 원래대로 돌아갔다..
이부분을 다른 팀원분이 봐주셨는데 봐주시면서 코드를 전체적으로 정리해주신 것 같다. 훨씬 더 깔끔하긴한데 확실하게 내가 한 수준은 아닌것 같다 ㅋㅋㅋㅋㅋ

크게 내가 기능적으로 구현한 코드 정리는 여기까지이다.
다음 글은 내가 프로젝트를 진행하면서 느낀점, 회고록을 작성할 예정이다.
아마 후기 얘기하면서 현재 페이지에서 언급한 코드들이 다시 나올 수 있지만 ㅋㅋㅋ 내가 코드 구현하면서 느낀 점 위주로 작성할 예정이다.

profile
자 이제 시작이야~ 내 꿈을~ 내 꿈을 위한 여행~~🌈

0개의 댓글