Project Shall-We-Health #3-1 클라이언트 기능 구현(매칭게시판, 관리자페이지)
시작하며
목업페이지를 완성하고 기능 구현을 시작하면서 이전의 프로젝트와 다른 점은 프론트엔드와 백엔드로 구현해야할 파트가 나누어져 있다는 것이다. 서버 구현은 다른 팀원 분이 담당하시기 때문에 클라이언트에서는 아직 구현되지 않은 서버의 응답을 예상해서 코드를 작성해야 해서 REST API 설계 단계가 중요했다.
목업페이지 연결
목업페이지가 완성되고 react-router-dom을 사용해서 링크가 있는 페이지들을 연결했는데, 처음 했던 프로젝트처럼 match를 사용하는 부분이 있어서 params를 설정해주었다.
<Route path='/view/:postId' component={View} /
<Route path='/verify-email/:token' component={VerifyEmail} />
<Route path='/updatepw/:token' component={UpdatePw} />
//View 컴포넌트
export default function View({match}) {
const postId= match.params.postId
return (
/*생략*/
)
}
매칭 정보를 보여주는 View 페이지에 위 코드처럼 :postId
로 path를 설정해주면 View 컴포넌트에서 Props로 postId(match.params.postId
)를 받아올 수 있다. 그리고 이전 프로젝트와 다르게 회원가입시 인증코드를 입력해서 메일 인증을 진행하는 방식이 아니라 링크를 접속했을때 인증이 진행되게 구현하기로 했기 때문에 token을 전달할 수 있도록 설정해 주었다. 이메일 정보가 암호화된 토큰이 포함된 회원가입 인증 링크가 메일로 전송되고 해당 링크에 접속시 서버에 토큰과 함께 인증 요청이 전달되면 서버에서 토큰을 복호화하고 해당 유저의 인증을 진행하는 방식으로 로직을 구현하였다. 비밀번호 찾기 역시 같은 방식으로 구현될 예정이므로 params를 설정해주었다.
매칭 게시판
게시판 페이지는 이전에 다가치 프로젝트나 느린 우체통의 관리자 페이지에서 구현했기 때문에 어려운 로직은 없었다. 우선 서버에서 응답받기 전에 보여질 로딩 페이지와 데이터가 없을때 보여질 페이지를 state를 사용하여 연결하고 REST API대로 맞춰서 매칭 정보가 표시될 수 있도록 변경하였다. 목업페이지를 만들때 더미데이터가 map으로 표시되도록 구현해놓아서 서버 요청 관련 코드를 제외하고 크게 추가되지는 않았다.
날짜, 페이지, 신청가능만 표시 여부, 검색어를 state로 설정하여 매칭 정보 요청시 전달되게 하였다. 다가치 프로젝트에서도 필터링 요소가 많은 게시판을 구현해보아서 어려운 로직은 아니였다. useEffect를 사용해서 필터링 정보가 바뀔때 데이터를 요청할 수 있도록 설정하였고 클릭시 해당하는 view 페이지로 이동할 수 있도록 Link를 사용하여 연결해주었다.
한 페이지에 표시되는 데이터가 많기 때문에 스크롤이 내려갔을 때 표시되고 누르면 스크롤이 위로 올라가는 버튼을 추가하고 기능구현을 마쳤다. 추후에 서버가 구현되면 버그없이 작동되는지 확인할 예정이다.
export default function Board() {
const [ page, setPage ] = useState(1)
const [ count, setCount ] = useState(0)
const [ selectDate, setSelectDate ] = useState(0)
const [ selectLocation, setSelectLocation] = useState('전체')
const [ locationForm, setLocationForm ] = useState('%')
const [ data, setData ] = useState([])
const [ isLoading, setIsLoading ] = useState(false)
const [ isMatched, setIsMatched ] = useState(null)
const [ keyword, setKeyword ] = useState(null)
const [ ScrollY, setScrollY ] = useState(0);
const [ btnStatus, setBtnStatus ] = useState(false);
const handleFollow = () => {
setScrollY(window.pageYOffset);
if(ScrollY > 100) {
setBtnStatus(true);
} else {
setBtnStatus(false);
}
}
useEffect(() => {
const watch = () => {
window.addEventListener('scroll', handleFollow);
}
watch();
return () => {
window.removeEventListener('scroll', handleFollow);
}
})
const handleTop = () => {
window.scrollTo({
top: 0,
behavior: "smooth"
});
setScrollY(0);
setBtnStatus(false);
}
const settings = {
dots: false,
infinite: false,
speed: 500,
slidesToShow: 10,
slidesToScroll: 1,
};
const getDateForm = (n) => {
let today = new Date();
let date = new Date(today.setDate(today.getDate() + n));
let year = date.getFullYear();
let month = ('0' + (date.getMonth() + 1)).slice(-2);
let day = ('0' + date.getDate()).slice(-2);
return year + '-' + month + '-' + day;
}
const getDateArr = (n) => {
let today = new Date();
let date = new Date(today.setDate(today.getDate() + n));
let day = date.getDate();
return day;
};
const getDaysArr = (n) => {
let today = new Date();
let days = ["일", "월", "화", "수", "목", "금", "토"];
let date = new Date(today.setDate(today.getDate() + n));
return days[date.getDay()];
};
const dateArray = Array.from({ length: 14 }, (v, i) => getDateArr(i));
const daysArray = Array.from({ length: 14 }, (v, i) => getDaysArr(i));
const locationArr = ['전체', '서울', '경기', '인천', '대전', '충북', '충남', '대구', '부산', '울산', '경북', '경남', '광주', '전북', '전남', '강원', '제주']
const handleLocation = (e) => {
setSelectLocation(e.target.innerText)
if(e.target.innerText==='전체') {
setLocationForm('%')
} else {
setLocationForm(e.target.innerText)
}
}
const hadleKeyword = (e) => {
if(e.target.value==='') {
setKeyword(null)
} else {
setKeyword(e.target.value)
}
}
//데이터 요청
const getData = async() => {
await setIsLoading(true)
await axios.get(`${process.env.REACT_APP_SERVER_API}/post`, {
params: {
date : getDateForm(selectDate),
location : locationForm,
page,
isMatched,
keyword
}
})
.then((res) => {
setData(res.data.data)
setCount(res.data.count)
})
await setIsLoading(false)
}
const getDataPage = () => {
axios.get(`${process.env.REACT_APP_SERVER_API}/post`, {
params: {
date : getDateForm(selectDate),
location : locationForm,
page,
isMatched,
keyword
}
})
.then((res) => {
setData(res.data.data);
setCount(res.data.count);
handleTop();
})
}
useEffect(()=>{
getData();
},[selectDate, locationForm, isMatched])
useEffect(()=>{
getDataPage();
},[page])
return (
<div className="board-container">
<div className="box-banner">
<BannerSlider />
</div>
<div className="box-date">
<Slider {...settings}>
{dateArray.map((el,i)=> {
return(
<DateBtn selectDate={selectDate} setSelectDate={setSelectDate} key={i} i={i} date={el} days={daysArray[i]}/>
)
})}
</Slider>
</div>
<ul className='box-location'>
{locationArr.map((el,i) => {
return (
<li className={selectLocation===el ? 'btn-selected-location' : 'btn-location'} onClick={handleLocation}>{el}</li>
)
})}
</ul>
<div className='box-filter'>
<input type='checkbox' id='match-out' className='match-check-box' checked={isMatched} onChange={(e)=>{setIsMatched(e.target.checked)}}/>
<label for='match-out' className='text-match'>신청 가능만 보기</label>
<div class='search-box'>
<input
type='text'
id='search'
placeholder='헬스장 명을 입력하세요'
onChange={hadleKeyword}
></input>
<span>
<button id='searchButton' onClick={getData}>
<FontAwesomeIcon icon={faSearch}/>
</button>
</span>
</div>
</div>
<div className='box-list'>
<table className='table-data'>
{isLoading ? (
<tr className='box-loading'>
<td colSpan='3'>
<Loading/>
</td>
</tr>
) : (data.length===0 ? (
<tr className='box-none'>
<td colSpan='3'>일치하는 게시물이 없습니다.</td>
</tr>
) :(data.map((el,i)=> {
return(
<RowData el={el} key={i}/>
)
})))}
</table>
</div>
<div className='box-pagination'>
<Pagination
activePage={page}
itemsCountPerPage={15}
totalItemsCount={count}
pageRangeDisplayed={5}
prevPageText={'‹'}
nextPageText={'›'}
onChange={setPage}
/>
</div>
<div className={btnStatus?'btn-top':'btn-top none'} onClick={handleTop}><FontAwesomeIcon icon={faCaretUp}/></div>
</div>
);
}
function BannerSlider() {
const settings = {
dots: false,
infinite: true,
speed: 500,
slidesToShow: 1,
slidesToScroll: 1,
};
return (
<div>
<Slider {...settings}>
<Banner />
<Banner />
<Banner />
</Slider>
</div>
);
}
function Banner() {
return (
<div className="banner">
<img className="img-banner" alt="logo" src="img/logo.svg" />
</div>
);
}
function DateBtn({ selectDate, setSelectDate, days, date, i }) {
return (
<div className={selectDate===i ? "btn-selected-date" : 'btn-date'} onClick={()=>{setSelectDate(i)}}>
<div className={days==='토' ? 'date blue' : (days==='일' ? 'date red' : 'date')}>{date}</div>
<div className={days==='토' ? 'days blue' : (days==='일' ? 'days red' : 'days')}>{days}</div>
</div>
);
}
function RowData({el}) {
return(
<tr>
<td className='time'><Link to={`/view/${el.id}`} style={{ color: 'inherit', textDecoration: 'inherit' }}>{el.reserved_at.slice(11,16)}</Link></td>
<td className='info'>
<div className='title'><Link to={`/view/${el.id}`} style={{ color: 'inherit', textDecoration: 'inherit' }}>{el.location.address_name.slice(0,2)+' '+el.location.place_name}</Link></div>
<div className='sub-info'><Link to={`/view/${el.id}`} style={{ color: 'inherit', textDecoration: 'inherit' }}>3대 {el.description.sbd} {el.description.bodyPart.join(' ')}</Link></div>
</td>
<td className='match'>
<Link to={`/view/${el.id}`} style={{ color: 'inherit', textDecoration: 'inherit' }}>
{!el.isMatched ? <div className='btn-match'>신청 가능</div> : <div className='btn-match-end'>마감</div>}
</Link>
</td>
</tr>
)
}
관리자 페이지
관리자 페이지는 유저 관리, 매칭 관리, 신고 내역 확인 총 3개의 페이지가 탭 메뉴로 연결되어 있는데, 매칭 게시판 페이지와 로직이 비슷하고 삭제 기능(매칭 관리, 유저 관리 해당)만 추가된 경우라서 빠르게 구현하였다.
관리자 페이지에서 관리자가 아닌 경우 접근할 수 없도록 쿠키의 accessToken을 서버에 전달하여 검증하는 과정을 추가해주었다.
export default function AdminPage() {
const [ tap, setTap ] = useState(0)
const [modal, setModal] = useState(false) // 접근 불가 모달 창
const checkisAdmin = () => {
axios.get(`${process.env.REACT_APP_SERVER_API}/user/auth`, {
withCredentials: true,
})
.then((res) =>{
if(!res.data.data.isAdmin) {
setModal(true)
}
})
.catch(()=>{
setModal(true)
})
}
useEffect(()=>{
checkisAdmin();
},[])
return (
/*생략*/
)
}
유저 관리와 매칭 관리 페이지에서 데이터 삭제가 가능하기 때문에 마찬가지로 accessToken을 전달하여 서버에서 관리자인지 확인할 수 있도록 검증하는 단계를 거치도록 설정하였다.
const deleteMatch = () => {
axios
.delete(`${process.env.REACT_APP_SERVER_API}/admin/post`, {
data: { postId: deleteId },
withCredentials: true,
})
.then((res) => {
setModal(true);
})
.catch((res) => {
setErrorModal(true);
});
};
마치며
클라이언트의 기능 구현을 진행하면서 실제 현업에서 REST AP 기획이 얼마나 중요한지 알 수 있었다. 기능 구현을 위해 필요한 데이터가 빠져 있거나 요청을 추가해야 하는 경우가 많아서 백엔드를 담당하신 팀원분과 계속 소통하면서 기능 구현을 진행했다. 이전에는 서버와 클라이언트의 기능 구현을 동시에 했기 때문에 구현 단계에서 버그를 확인할 수 있어서 좋았지만 지금은 REST API대로 구현하고 추후 확인해야 했다. 처음에는 불편하다고 생각했지만 서버에서 처리할 요청이 많아지고 분기를 나누어야 할 에러들이 많다면 백엔드에서 통일된 규칙으로 핸들링하는 것이 더 효율적이라는 생각이 들었다.
Author And Source
이 문제에 관하여(Project Shall-We-Health #3-1 클라이언트 기능 구현(매칭게시판, 관리자페이지)), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@bbaa3218/Project-Shall-We-Health-3-1-클라이언트-기능-구현매칭게시판-관리자페이지저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)