Demo : https://youtu.be/IhYx9S43GYw
메인 페이지
모달 형식의 페이지 레이아웃 구현
예약확인 페이지
호스트등록 페이지
구글과 페이스북의 소셜 로그인 API의 사용하기 위한 과정은 다음과 같았다.
const Login = (props) => {
const googleLogin = () => {
window.gauth.signIn().then(function () {
let token = window.gauth.currentUser.get().wc.access_token;
console.log(window.gauth.currentUser.get());
localStorage.setItem("access_token", token);
fetch(`${API}/user/google`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
access_token: token,
}),
.then(window.location.reload()),
});
});
};
return (
<Back>
<AccountModal>
<div>
<button onClick={props.handleClose}>x</button>
</div>
<div>
<button onClick={googleLogin}>구글 로그인</button>
<button
onClick={() =>
window.gauth.signOut().then(function () {
window.localStorage.clear();
window.location.reload();
})
}
>
로그아웃
</button>
</div>
</AccountModal>
</Back>
);
};
export default Login;
로그인 기능외에도 버튼 커스텀, 에러핸들링등등 여러 기능이 달린 라이브러리가 있길래 대체하였다.
구글과 페이스북으로 요청하여 응답받은 토큰을 body에 담아 백엔드로 보내고 회가입여부를 체크하고 ,
다시 우리 서버에서 보내준 토큰을 local storage에 담아서 로그인상태를 유지할 수 있도록 했다.
const Login = ({ openLogin, handleClose }) => {
//구글 로그인 성공시
const responseGoogle = (response) => {
console.log(response);
fetch(`${API}/user/google`, {
method: "POST",
headers: {
"Content-type": "application/json",
},
body: JSON.stringify({
access_token: response.wc.access_token,
}),
})
.then((response) => response.json())
// .then((response) => console.log(response));
.then((response) => {
localStorage.setItem("access_token", response.Authorization);
localStorage.setItem("username", response.username);
localStorage.setItem("avatar", response.avatar);
window.location.reload();
});
};
...
return (
<Portal elementId="modal-root">
<Back onClick={openLogin && handleClose}>
<AccountModal>
<div>
<button onClick={handleClose}>
...
</button>
</div>
<div>
...
<GoogleLogin
clientId="515529931906-c9va4d88rfupqbr81mi9se4jjbmvkm6o.apps.googleusercontent.com"
render={(renderProps) => (
<GoogleLoginBtn
onClick={renderProps.onClick}
disabled={renderProps.disabled}
>
...
구글 로그인
</GoogleLoginBtn>
)}
buttonText="Login"
onSuccess={responseGoogle}
onFailure={responseFail}
cookiePolicy={"single_host_origin"}
/>
const Nav = () => {
const history = useHistory();
const [loginBtn, setLoginBtn] = useState("로그인"); //로그인버튼의 text
const [loginAvatar, setLoginAvatar] = useState(""); //프로필이미지url
const [openLogin, setOpenLogin] = useState(false); //로그인을 위한 모달 띄우기
const [openModal, setOpenModal] = useState(false); //로그인상태에만 나타나는 모달띄우기
useEffect(() => {
//로그아웃상태시 로그인버튼에는 초기상태인 "로그인"이 표시되고
//로컬스토리지에 토큰이있다면(로그인시) 사용자 이름과 프로필 이미지를 nav바에 표시해준다.
if ("access_token" in localStorage) {
const username = localStorage.getItem("username");
const avatar = localStorage.getItem("avatar");
setLoginAvatar(<img src={`${avatar}`} className="avatar" alt=""/>)
setLoginBtn(`${username}`);
}
}, [loginBtn]);
//loginBtn state를 통해 로그인여부를 확인할 수 있다.
//로그아웃상태라면 로그인 모달을 열고, 로그인상태라면 회원전용 기능이 있는 모달창이 열린다.
const handleUserState = () => {
if (loginBtn === "로그인") {
setOpenLogin(!openLogin);
} else {
setOpenModal(!openModal);
}
};
//모달창 닫기 위한 함수정의
const handleClose = () => {
setOpenLogin(false);
};
//로그아웃버튼을 누르면 로컬스토리지를 비우고, 버튼 표시를 다시 "로그인"으로 바꾼다.
const handleLogout = () => {
window.localStorage.clear();
window.location.reload();
setLoginBtn("로그인");
};
return (
<Background onClick={openModal && handleClose}>
<NavWrapper>
...
<div className="LoginBtn nav" onClick={handleUserState}>
{loginBtn}{loginAvatar}
{openModal && (
<UserModal
openModal={openModal}
handleUserState={handleUserState}
handleLogout={handleLogout}
/>
)}
</div>
</NavButton>
</NavWrapper>
{openLogin && <Login openLogin={openLogin} handleClose={handleClose} />}
</Background>
로그인상태에서만 회원전용 페이지로 갈 수 있는 UserModal을 띄울 수 있고, useHistory hook을 이용하였다.
const UserModal = ({ handleLogout, openModal, handleUserState }) => {
const history = useHistory();
return (
<Portal elementId="modal-root">
<Background onClick={openModal && handleUserState}>
//모달창처럼 뜨고, 따로 닫기버튼이 없어 모달 밖의 부분을 클릭하면 openModal 상태가 false로 바뀌어 닫힌다.
<User>
<div
onClick={() => {
history.push("/trips");
}}
>
여행
</div>
<div
onClick={() => {
history.push("host");
}}
>
숙소 호스트되기
</div>
<Line></Line>
<div className="bottom" onClick={handleLogout}>
로그아웃
</div>
</User>
</Background>
</Portal>
);
};
export default UserModal;
리액트에서 Portal은 컴포넌트를 렌더링하게 될 때, UI 를 어디에 렌더링 시킬지 DOM 을 사전에 선택하여 부모 컴포넌트의 바깥에 렌더링 할 수 있게 해주는 기능이다. 따라서 DOM 의 계층구조 시스템에 종속되지 않으면서 컴포넌트를 렌더링 할 수 있다.
그래서 부모 컴포넌트가 overflow: hidden 이나 z-index 스타일을 가지지만, 자식이 컨테이너에서 시각적으로 이탈해야하는 경우 유용하다.(다이얼로그나, 호버카드나, 툴팁, 모달창에 유용!)
내용출처: 공식문서/https://velog.io/@public_danuel/trendy-react-portals
const Portal = ({ children }) => {
const el = useMemo(() => document.getElementById("modal-root"));
return ReactDOM.createPortal(children, el);
};
export default Portal;
유저가 검색하는 값은 리덕스 모듈을 통해 전역상태로 관리된다.
메인>리스트>상세페이지>예약페이지까지 검색결과가 반영되기때문에, 처음에는 props나 window.history를 이용해서 전달전달하는 방식이었지만, 비효율적인 것 같아서 프로젝트기간이 끝난 후에 다시 뜯어고치고, 다른 팀원이 완성한 페이지까지 손을 좀 대야했다.
const Search = ({
history,
searchActions,
startDay,
endDay,
location,
adults,
children,
infants,
}) => {
//위치입력 input에 달린 핸들러
const onChange = (e) => {
searchActions.getLocation(e.target.value);
};
//메인에서 검색한 값이 리스트페이지에서 쿼리스트링형식으로 필터링되기때문에,
//검색버튼을 누르면 바로 그 주소로 보내도록 했다.
const goToList = () => {
const checkinString = `&checkin=${startDay}`;
const checkoutString = `&checkout=${endDay}`;
const adultsString = `&adults=${adults}`;
const childrenString = `&children=${children}`;
const infantsString = `&infants=${infants}`;
//위치입력만 필수이고, 날짜나 게스트숫자는 입력하지않아도
//보내는 주소에 반영되지않으면서 검색할 수 있도록 했다.
if (!location) {
alert("위치를 입력하세요");
} else {
history.push(
`/list?location=${location}${startDay ? checkinString : ``}${
endDay ? checkoutString : ``
}${adults ? adultsString : ``}${children ? childrenString : ``}${
infants ? infantsString : ``
}`
);
}
};
return (
<>
<SearchWrapper>
<SearchInputs>
<SearchInput>
<InputBtn>
<div className="location"> 위치</div>
<i className="fas fa-search min"></i>
<form onSubmit={goToList}>
<input
placeholder="어디로 여행가세요?"
onChange={onChange}
// value={location}
></input>
</form>
</InputBtn>
</SearchInput>
<Line></Line>
<SearchInput className="media">
<InputBtn>
<div> 체크인/체크아웃</div>
<div>
{/* 캘린더 컴포넌트 */}
<Calendar />
</div>
</InputBtn>
</SearchInput>
<Line></Line>
{/* 게스트 인원 컴포넌트 */}
<SearchInput className="media">
<Guest />
</SearchInput>
</SearchInputs>
<SearchBtn onClick={goToList}>
<i className="fas fa-search"></i> <span>검색</span>
</SearchBtn>
</SearchWrapper>
</>
);
};
에어비앤비에서 제공하는 react-dates라는 캘린더 라이브러리를 활용하였다.
class형으로 만들어진 컴포넌트라 함수형 컴포넌트로 직접 수정하고, 선택한 날짜가 moment객체로 담기기때문에, 백엔드에 보내줘야하는 날짜형식으로 format해주었다.
const Calendar = ({
searchActions,
startDay,
endDay,
displayHandler,
totalPriceCalculator,
}) => {
const [startDate, setStartDate] = useState(null);
const [endDate, setEndDate] = useState(null);
const [focusedInput, setFocusInput] = useState(null);
const onDatesChange = ({ startDate, endDate }) => {
setStartDate(startDate);
setEndDate(endDate);
//날짜형식 변환
let checkin, checkout;
if (startDate !== null && endDate !== null) {
checkin = startDate.format("YYYY-MM-DD");
checkout = endDate.format("YYYY-MM-DD");
//상세페이지에서만 적용되는 핸들러를 실행하기 위한 로직
//displayHandler,totalPriceCalculator라는 prop은 메인페이지에서는 전달되지 않고, 실행되면 안되기때문에 조건을 걸어두었다.
displayHandler && displayHandler();
totalPriceCalculator && totalPriceCalculator(startDate, endDate);
}
//리덕스 store에 저장
searchActions.getStartDay(checkin);
searchActions.getEndDay(checkout);
};
return (
<>
<DateRangePicker
startDate={startDate}
startDateId="your_unique_start_date_id"
endDate={endDate}
endDateId="your_unique_end_date_id"
onDatesChange={({ startDate, endDate }) =>
onDatesChange({ startDate, endDate })
focusedInput={focusedInput}
onFocusChange={(focusedInput) => setFocusInput(focusedInput)}
showClearDates={true}
startDatePlaceholderText={!startDay ? "체크인" : startDay}
endDatePlaceholderText={!endDay ? "체크아웃" : endDay}
/>
</>
);
};
const Guest = ({ searchActions, adults, children, infants }) => {
//인원 칸을 클릭하면 카운터가 나타남
const [open, setOpen] = useState(false);
const handleOpen = () => {
setOpen(!open);
};
//인원선택을 안했을 시, "게스트추가" 라는 표시가 되고,
//선택시 성인+어린이가 게스트인원으로 계산되고, 유아는 따로 표시된다.
let guestNum;
if (adults === 0 && children === 0 && infants === 0) {
guestNum = `게스트 추가`;
} else if (infants === 0) {
guestNum = `게스트 ${adults + children}명`;
} else if (infants !== 0) {
guestNum = `게스트 ${adults + children}명, 유아 ${infants}명`;
}
return (
<>
<SearchInput>
<InputBtn onClick={handleOpen}>
<div>인원</div>
<div>{guestNum}</div>
</InputBtn>
{open && <GuestModal /> }
</SearchInput>
</>
);
};
단순히 +1, -1만 되는 카운터가 아니라, 실제 에어비앤비 사이트에서 적용되는 조건을 전부 반영하였다.
const GuestModal = ({ searchActions, adults, children, infants }) => {
return (
<Modal>
<Guests>
<div>
<span>성인</span>
<span>만 13세 이상</span>
</div>
<div>
<button
className={adults === 0 && "disabled"}
onClick={() => {
if (adults === 1 && (children !== 0 || infants !== 0)) {
searchActions.getAdults(1);
} else if (adults !== 0) {
searchActions.adultDecrement();
}
}}
>
-
</button>
<div>{adults}</div>
<button
onClick={() => {
if (adults !== 16) {
searchActions.adultIncrement();
}
}}
>
+
</button>
</div>
</Guests>
<Guests>
<div>
<span>어린이</span>
<span>2~12세</span>
</div>
<div>
<button
className={children === 0 && "disabled"}
onClick={() => {
if (children !== 0) {
searchActions.childrenDecrement();
}
}}
>
-
</button>
<div>{children}</div>
<button
onClick={() => {
if (adults === 0 && children === 0) {
searchActions.getAdults(1);
searchActions.getChildren(1);
} else if (children !== 16) {
searchActions.childrenIncrement();
}
}}
>
+
</button>
</div>
</Guests>
<Guests>
<div>
<span>유아</span>
<span>2세 미만</span>
</div>
<div>
<button
className={infants === 0 && "disabled" }
onClick={() => {
if (infants !== 0) {
searchActions.infantsDecrement();
}
}}
>
-
</button>
<div>{infants}</div>
<button
className={infants === 5 && "disabled" }
onClick={() => {
if (adults === 0 && infants === 0) {
searchActions.getAdults(1);
searchActions.getChildren(1);
} else if (infants !== 5) {
searchActions.infantsIncrement();
}
}}
>
+
</button>
</div>
</Guests>
</Modal>
);
};
로그인 시 유저의 예약 상황을 볼 수 있는 페이지이다.
예정된 예약, 이전예약 클릭시 페이지가 다시 로딩되지 않고 각각 다른 컴포넌트를 보여줘야한다.
const Trips = () => {
const [activeTab, setActiveTab] = useState("upcoming"); //클릭한 Tab이 무엇인지
const [upcoming, setUpcoming] = useState([]); //예정된예약데이터
const [past, setPast] = useState([]); //이전예약데이터
useEffect(() => {
// 로컬스토리지에 담겨진 토큰을 헤더에 담아 인증하고 데이터를 응답받는다.
const token = localStorage.getItem("access_token");
fetch(`${API}/user/tripstate`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: token,
},
})
.then((res) => res.json())
.then((res) => {
//이전예약, 예정된예약 데이터를 각각 다른 state에 담아줌
setUpcoming(res.data[0].up_coming);
setPast(res.data[0].past_booking);
})
.catch((err) => alert(err));
}, []);
const tab = {
upcoming: (
<div>
// 응답받은 데이터에 값이 없으면 메세지를 보여주고,
// 있으면 state에 저장한 데이터를 TripList컴포넌트로 prop로 전달하여 목록을 보여준다.
{upcoming.length === 0 ? (
"다시 여행을 떠나실 준비가 되면 에어비앤비가 도와드리겠습니다."
) : (
<TripList list={upcoming} activeTab={activeTab} />
)}
</div>
),
past: (
<div>
{past.length === 0 ? (
"과거 여행이 없습니다. 하지만 여행을 완료하면 여기에서 확인하실 수 있습니다."
) : (
<TripList list={past} activeTab={activeTab} />
)}
</div>
),
};
return (
<>
<Wrap>
<Nav />
<TripsWrapper>
<section>
<h2>여행</h2>
<Buttons>
<button
style={
activeTab === "upcoming"
? {
color: "black",
}
: undefined
}
onClick={() => setActiveTab("upcoming")}
>
예정된 예약
</button>
<button
style={
activeTab === "past"
? {
color: "black",
}
: undefined
}
onClick={() => setActiveTab("past")}
>
이전 예약
</button>
</Buttons>
<Tab>{tab[activeTab]}</Tab>
...
const TripList = (props) => {
const list = props.list.map((item) => {
return (
<Trip
id={item.id}
title={item.title}
image_url={item.image_url}
start_date={item.start_date}
end_date={item.end_date}
address={item.address}
room_id={item.room_id}
host_id={item.host_id}
activeTab={props.activeTab}
/>
);
});
return <TripLists>{list}</TripLists>;
};
const Trip = (props) => {
const [openComment, setOpenComment] = useState(false);
const handleOpen = () => {
const token = localStorage.getItem("access_token");
fetch(`${API}/api/review/${props.room_id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: token,
},
})
.then((res) => {
if (res.status === 200) {
setOpenComment(!openComment);
} else {
alert("이미 작성한 후기가 있습니다.");
}
});
.catch((err) => alert(err));
};
const handleClose = () => {
setOpenComment(false);
};
return (
<TripThumbnail>
//클릭하면 해당 숙소의 상세페이지로 넘어가도록 한다.
<Link to={`/detail/${props.room_id}`}>
<img src={`${props.image_url[0]}`} alt="" />
<div>
<span>
{props.start_date} - {props.end_date}
</span>
<span>{props.address}</span>
<span>{props.title}</span>
</div>
</Link>
//예정된 예약에는 리뷰달기 버튼이 보여지면 안되기때문에 props값에 따라 스타일 적용.
<div
style={{ display: props.activeTab === "upcoming" ? "none" : "block" }}
>
<Btn onClick={handleOpen}>리뷰 쓰기</Btn>
</div>
//리뷰쓰기 버튼을 누르면 openComment 상태가 true가 되고, Form 컴포넌트가 나타난다.
{openComment &&
<Form
roomId={props.room_id}
hostId={props.host_id}
handleClose={handleClose}
/>
) }
</TripThumbnail>
);
};
실제 에어비앤비에서 호스트 등록하려면 몇십개의 input이 필요하고, 10페이지 이상이 연결되어있다.
짧은 기간에 끝내야하기때문에 UI가 좀 못생겨져서 아쉽지만 20개정도의 input을 1페이지로 아주 간략하게 구성해보았다.
//각각의 input과 버튼마다 다른 이벤트핸들러를 전달하지않고, name과 value를 활용해서 한개의 함수로 통합했다
onChange = (e) => {
const { value, name } = e.target;
this.setState({ ...this.state, [name]: value }); //name 키를 가진 값을 value 로 설정
};
handlePlus = (e) => {
e.preventDefault();
const { name } = e.target;
this.setState((current) => ({ ...this.state, [name]: current[name] + 1 }));
};
handleMinus = (e) => {
e.preventDefault();
const { name } = e.target;
if (this.state[name] !== 0) {
this.setState((current) => ({ ...this.state, [name]: current[name] - 1 }));
}
};
//input태그의 type이 checkbox이면 여러개 선택 가능하다.
//클릭하면 state에 담고, 다시 클릭하면 state에서 빼줘야하기때문에 filter메소드를 이용했다.
handleMulti = (e) => {
const { value } = e.target;
let selectedTypes = [...this.state.features];
if (selectedTypes.includes(value)) {
selectedTypes = selectedTypes.filter((s) => s !== value);
} else {
selectedTypes = [...selectedTypes, value];
}
this.setState({ features: selectedTypes });
};
콘솔에는 이미지가 배열로 잘 담긴걸 확인하고, 숙소데이터로 추가되서 리스트와 상세페이지에서 보여지는 걸 확인했지만.. 이미지가 빈값으로 들어온다고한다. 백엔드에서 수정을 해야하는 것 같다고는 하는데 프로젝트 기간이 끝나서 해결못한 부분이다
onDrop = (images) => {
//업로드한 사진을 state의 images 값으로 담는다.
this.setState({
images: this.state.images.concat(images),
});
};
onFetch = () => {
const token = localStorage.getItem("access_token");
fetch(`${API}/api/register/room`, {
method: "POST",
headers: {
"Content-Type":
"multipart/form-data; boundary=----WebKitFormBoundaryIn312MOjBWdkffIM",
Authorization: token,
},
body: JSON.stringify({
inputs: this.state,
}),
});
};
handleSubmit = (e) => {
e.preventDefault();
//빈 FormData객체를 생성
const formData = new FormData();
//state에 담아놓은 이미지파일들을 append 메소드로 키-값 형식으로 전부 추가한다
for (let i = 0; i < this.state.images.length; i++) {
formData.append("images", this.state.images[i]);
}
// 변환된 폼데이터로 대체하고, 전송한다
this.setState({ images: formData }, this.onFetch);
};
리액트를 일주일 공부하고 연달아 짧은 기간의 프로젝트를 진행하였더니, 마음이 급해 기능 구현에만 급급했던 것 같다.
앞으로 코드를 짤 때 개선하고 싶은 점은