FAQ페이지 구현하기
이전의 옥소폴리틱스 메인페이지 클론코딩을 성공적으로 마치고 다음 과제에 대해 회의하는 시간이 있었다. 옥소 사 멘토분들과 팀원들과의 회의에서 나온 의견으로는 다른 페이지 클론 코딩, 현재 구현한 메인페이지에 Firebase 적용하기, 아예 새로운 제품 개발해보기, notion으로 구현된 페이지 React로 구현하기 등이 있었다. 새로운 제품을 개발해보기에는 시간이 빠듯했기에 현재 옥소폴리틱스에서 notion으로 구현되있는 페이지 중 하나를 React를 이용해 웹페이지로 구현해보기로 하였다. 현재 옥소폴리틱스의 FAQ 페이지는 다음과 같이 노션으로 구현되어 있다.
노션 페이지 자체의 디자인은 나쁘지 않지만 기존 옥소폴리틱스의 다른 페이지들과는 이질감이 많이 느껴졌다.
Figma를 이용해 프로토타입 제작
옥소폴리틱스의 디자이너분께 직접 여쭤본 것은 아니지만 조금만 살펴봐도 옥소의 디자인 언어를 알 수 있었다. 버튼들은 기본적으로 boder-radius: 15px이 적용되며 버튼은 black 배경에 white 폰트 컬러를 사용한다. 또한 다른 페이지(메인페이지, 폴디 페이지)들의 마진값과 폰트 크기, 굵기 등을 참고하여 Figma를 이용해 직접 프로토타입을 제작하였다!
활성화된 버튼은 기본적으로 black&white를 적용하였고 다른 요소의 border는 옥소 gray라 불리는 #E6E6E6 색상을 적용하였다. 화살표를 이용해 각 질문&답변 요소의 열리고 닫힘을 표현하였다. 검색 기능은 카테고리 별로 구현하기 위해 카테고리 영역 밑에 배치하였다. 카테고리 영역은 횡스크롤을 지원하려고 하였다.
Firebase 적용하기
이미 클론 코딩을 통해 리액트로 페이지를 만들어 본 경험이 있기 때문에 사실 위의 페이지를 구현만 하는 것은 개인이 연습 프로젝트로 하루정도 투자하면 끝낼 수 있는 규모였다. 때문에 우리 팀은 난이도를 높여 FAQ 데이터를 Firebase로부터 받아 동적으로 출력할 수 있게 구현하기로 하였다. 추가로 admin 계정을 설정하여 FAQ 데이터의 CRUD가 가능하도록 구현하기로 하였다.
React로 정적 페이지 만들기
우선 파이어베이스를 적용하기 전에 Figma로 제작한 대로 UI 뼈대를 만들어 놓기로 하였다. 헤더 영역은 이전에 구현한 메인페이지 클론에서 가져와 사용하였다.
Category 영역
이번 프로젝트에서 데이터는 각 answer이고, 해당 answer 안에 작성 날짜와 제목, 내용, 카테고리 등의 데이터를 갖고 있는 것으로 하였다. 때문에 카테고리는 answer 안에 있는 category 들을 읽으면서 같이 읽는 것으로 하였다.
원래는 횡스크롤 방식으로 카테고리 영역을 구현할 생각이었으나, 옥소폴리틱스의 모바일 버전은 flutter를 활용한 App을 통해 사용이가능했기 때문에 굳이 받응형으로 제작할 필요가 없었다. 데스크탑만 고려하면 되는 UI였기 때문에 데스크탑에서 활용도가 낮은 횡스크롤 대신 전체 카테고리를 펼쳐서 보여주는 UI를 채택하였다.
모든 글의 카테고리들의 category 속성 값을 읽은 다음 겹치지 않은 카테고리를 배열로 저장하여 렌더링한다.
const getAnswers = async () => {
const q = query(usersCollectionRef);
onSnapshot(q, (snapshot) => {
let arr = ['FAQ'];
const dataArray = snapshot.docs.map((doc) => {
let category = doc.data().category;
if (!arr.includes(category)) {
arr.push(category);
}
});
setCategoriesArr(arr);
});
};
* Set을 이용하는 것도 괜찮을 방법일 듯 하다!
Answer 컴포넌트
각 답변은 Answer 컴포넌트를 만들어 관리하였다. 각 Answer는 배열로 받아 Array.map()을 통해 id를 별도로 저장해주었다. 카테고리와 검색어(cate, search) state는 끌어올려서 조상 컴포넌트인 FaqBody 컴포넌트에서 관리해주었다. Answer에 필요한 항목들은 map을 이용해 attribute로 전달해주었다.
// AnswersContainer.jsx
return (
<StyledWrapper>
//fatch 실패 시 렌더링 오류를 방지하기위해 answers 유무 검사
{!!answers &&
answers
.filter(
(ans) =>
//검색어가 포함된 항목만 선택하여 렌더링
ans.title.includes(search) || ans.description.includes(search)
)
.map((elem) => (
<Answer
ansArr={elem}
key={elem.id}
answerId={elem.id}
cate={cate}
search={search}
/>
))}
</StyledWrapper>
answer의 펼치기 기능은 active state를 설정하여 관리하였다. active state가 true라면 class에도 active를 추가하여 CSS까지 관리해주었다. isEdit state는 글의 수정여부를 판단하여 글 수정폼을 렌더링한다. 글의 수정과 삭제 버튼은 validAdmin이라는 함수를 만들어 admin 여부를 판별하여 보여주었다.
//validAdmin.js
const validAdmin = (id) => {
const ADMIN_UID = 'SOT3U2CfXxXlIJxUYkh79gD7WYj1';
console.log(id);
if (id === ADMIN_UID) {
return true;
} else {
return false;
}
};
//Answer.jsx
return (
<StyledWrapper className={`answerContainer${active ? ' active' : ''}`}>
<span onClick={answerCardClick}>
<h4>{search ? highlightText(title, search) : title}</h4>
{validAdmin(currentUser) ? (
<div className='adminButtons'>
<button onClick={handleUpdate} type='button'>
{isEdit ? '취소' : '수정'}
</button>
<button onClick={handleDelete} type='button'>
삭제
</button>
</div>
) : null}
<img src='img/arrow.svg' alt={active ? '닫힌 질문' : '열린 질문'} />
</span>
{isEdit && <>{getEditForm()}</>}
{active && !isEdit ? <>{makeText(description, search)}</> : null}
</StyledWrapper>
);
CSS - Styled Components
CSS는 모두 Styled-Components를 이용하여 적용해주었다. CSS가 다른 파일에 분리되어 있는 것보다는 같은 파일에 있는 것이 편리하여 CSS in JS 방식을 채택하였다. 소규모 프로젝트였기 때문에 대부분 컴포넌트마다 StyledWrapper 컴포넌트를 만들어 적용해주었다.
//Answer.jsx
...
const StyledWrapper = styled.div`
position: relative;
width: 709px;
box-sizing: border-box;
margin-bottom: 20px;
border: 1px solid #e6e6e6;
border-radius: 10px;
...
`