[NextJs 지도 개발 #4] 매장 상세 정보 랜더링 구현하기

김유진·2023년 4월 24일
0

Nextjs

목록 보기
7/9
post-thumbnail

하단 UI 생성하기

DetailSection이라는 컴포넌트를 하나 만들어 현재 선택한 매장에 대한 정보를 볼 수 있도록 해보자.

import { IoIosArrowUp } from 'react-icons/io';
import styles from '../../styles/detail.module.scss';
import { CURRENT_STORE_KEY } from '@/hooks/useCurrentStore';
import useSWR from 'swr';


const DetailSection = () => {
    const { data: currentStore } = useSWR(CURRENT_STORE_KEY)
    return (
        <div className = {styles.detailSection}>
            <div className = {styles.header}>
                <button className = {styles.arrowButton} disabled>
                    <IoIosArrowUp size = {20} color = "#666666"/>
                </button>
                {!currentStore && <p className = {styles.title}>매장을 선택해주세요</p>}
                {currentStore && <p className = {styles.title}>{currentStore.name}</p>}
            </div>
        </div>
    )
}

export default DetailSection;

useSWR을 사용하여서 현재 선택한 매장 정보에 대하여 선택한 다음, 선택한 매장이 없으면 매장을 선택해주세요 라는 플레이스홀더를 반환, 선택한 매장이 있다면 그 매장의 이름을 나타내도록 만든다.
현재 하단에 만들었기 때문에 네이버 로고가 가려져서, 네이버 로고를 가리지 않기 위하여 스타일을 작성하였다.

@use './detail.module';

.map {
  width: 100%;
  height: 100%;

  & > div:nth-of-type(2) {
    bottom: detail.$header-height + detail.$section-padding-top !important;
  }
}

하단 ui의 헤더 높이와 패딩 높이만큼 더 올려준다.

$header-height: 60px;
$section-padding-top: 8px;

.detailSection {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 100%;
  z-index: 101;

  display: flex;
  flex-direction: column;

  padding: $section-padding-top 16px 16px;
  background-color: white;
  color: #444444;

  border-top-left-radius: 24px;
  border-top-right-radius: 24px;
  box-shadow: 0 -2px 8px 0 rgba(136, 136, 136, 0.3);

  transform: translateY(
    calc(100% - #{$header-height} - #{$section-padding-top})
  );
}

그리고 만든 ui의 높이를 하단에 위치시키기 위해 전체 브라우저의 높이에서 헤더의 높이와 패딩의 높이만큼 제외시킨다.

버튼을 눌렀을 때 UI가 올라오도록 설정하기

expaned라는 토글 state를 하나 생성해준다.
true이면 UI가 끝까지 올라가고, false이면 UI보다는 지도를 보이게끔 설정한다.

const DetailSection = () => {
    const { data: currentStore } = useSWR<Store>(CURRENT_STORE_KEY);
    const [expanded, setExpanded] = useState(false);
    return (
        <div className = {`${styles.detailSection} ${expanded ? styles.expanded : ''} ${
            currentStore ? styles.selected : ''
          }`}>
            <div className = {styles.header}>
                <button className = {styles.arrowButton} 
                        onClick = {() => setExpanded(!expanded)}
                        disabled = {!currentStore}>
                    <IoIosArrowUp size = {20} color = "#666666"/>
                </button>
                {!currentStore && <p className = {styles.title}>매장을 선택해주세요</p>}
                {currentStore && <p className = {styles.title}>{currentStore.name}</p>}
            </div>
        </div>
    )
}

UI를 보이게끔 하는 전체 div에 조건에 따라 className이 변경되도록 설정한다.
만약 expanded가 true라면, 그와 관련된 class를 지정하고, currentStore가 있다면 selected 클래스를 사용할 수 있게끔 한다.
기본적으로 detailSection 클래스를 이용하기는 한다.

그리고 토글 버튼 UI에 onClick 설정을 남겨두어 토글기능을 넣고, 아무것도 선택되지 않을 때는 UI자체를 클릭하지 못하도록 한다.

상세페이지 완성하기

stores의 정보를 넘겨주어 자세한 정보를 보여주는 DetailContent 컴포넌트를 만들어보자.

const DetailContent = ({ currentStore, expanded }: Props) => {
  if (!currentStore) return null;
  return (
    <div
      className={`${styles.detailContent} ${expanded ? styles.expanded : ''}`}
    >
      <div className={styles.images}>
        {currentStore.images.slice(0, 3).map((image) => (
          <div
            style={{ position: 'relative', maxWidth: 120, height: 80 }}
            key={image}
          >
            <Image
              src={image}
              alt=""
              fill
              style={{ objectFit: 'cover' }}
              sizes="120px"
              placeholder="blur"
              blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO0WhFsDwADzwF2mLYSJgAAAABJRU5ErkJggg=="
              priority
            />
          </div>
        ))}
      </div>
	)
}

선택하면 그림이 보일 수 있게끔 해준다.
현재 가지고 있는 image url중에 3개만 뽑아서 보여주는데, url을 통하여 불러온 사진이기 때문에 크기를 모른다. 그렇기 때문에 부모 컴포넌트의 크기를 정해두고 그 안에 Image 태그를 이용하여 사진을 불러온다.

url로 불러온 이미지는 static하지 않아서 blur처리를 하기가 어려우므로 blur에 대한 url도 직접 전달한 모습을 볼 수 있다.

확장될 때 UI

const DetailContent = ({ currentStore, expanded }: Props) => {
  if (!currentStore) return null;
  return (
    <div
      className={`${styles.detailContent} ${expanded ? styles.expanded : ''}`}
    >
      <div className={styles.images}>
        {currentStore.images.slice(0, 3).map((image) => (
          <div
            style={{ position: 'relative', maxWidth: 120, height: 80 }}
            key={image}
          >
            <Image
              src={image}
              alt=""
              fill
              style={{ objectFit: 'cover' }}
              sizes="120px"
              placeholder="blur"
              blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO0WhFsDwADzwF2mLYSJgAAAABJRU5ErkJggg=="
              priority
            />
          </div>
        ))}
      </div>
      {expanded && (
        <>
          <div className={styles.description}>
            <h2>설명</h2>
            <p>{currentStore.description}</p>
          </div>
          <hr />
          <div className={styles.basicInfo}>
            <h2>기본 정보</h2>
            <div className="address">
              <IoLocationOutline size={20} />
              <span>{currentStore.address || '정보가 없습니다.'}</span>
            </div>
            <div className="phone">
              <IoCallOutline size={20} />
              <span>{currentStore.phone || '정보가 없습니다.'}</span>
            </div>
            <div className="naverUrl">
              <Image src={Naver} width={20} height={20} alt="" />
              <a
                href={`https://pcmap.place.naver.com/restaurant/${currentStore.nid}/home`}
                target="_blank"
                rel="noreferrer noopener"
              >
                <span>네이버 상세 정보</span>
              </a>
            </div>
          </div>
          <hr />
          <div className={styles.menus}>
            <h2>메뉴</h2>
            <ul>
              {currentStore.menus?.map((menu) => (
                <li className={styles.menu} key={menu.name}>
                  <span className={styles.name}>{menu.name}</span>
                  <span className={styles.price}>{menu.price}</span>
                </li>
              ))}
            </ul>
          </div>
        </>
      )}
    </div>
  );
};

확장될 때에는 가게의 정보 모든 것이 보여야 한다. 그렇기 때문에 상세정보들을 그대로 랜더링 할 수 있게끔 하고 expanded의 값이 true일때만 동작한다.

짜잔! 완성된 모습이다.

Detail UI를 별도의 페이지로 관리하기

식당 디테일 정보를 공유하는 기능을 만들기 위하여 해당 UI를 별도의 페이지로 관리할 수 있게끔 페이지를 분리해보자.
해당 식당의 이름으로 경로가 생길 수 있도록 [name.tsx]파일을 만든다.
그리고 경로를 생성할 수 있도록 getStaticPaths함수를 이용하여 미리 매장 이름으로 경로를 만들어둔다.

export const getStaticPaths: GetStaticPaths = async () => {
    const stores = (await import('../public/stores.json')).default;
    const paths = stores.map((store) => ({ params: { name: store.name}}));
    return { paths, fallback : true};
}

getStaticPaths는 pre-rendering을 통하여 특정 경로를 생성해둔다. 이 함수가 리턴하는 값은 무엇이 있는지 알아보자.

paths

return {
  paths: [
    { params: { id: '1' }},
    {
      params: { id: '2' },
      locale: "en",
    },
  ],
  fallback: ...
}

paths는 생성하고자 하는 링크명이고, pre-render를 어떤 것으로 할건지 지정할 수 있다. 위의 예시로는 아래 링크가 생성될 것이다. pages/psots/[id].js
params는 페이지의 이름과 일치해야 한다.
만약 페이지의 이름이 pages/posts/[postId]/[commentId]라면, params는 postIdcommentId를 가지고 있어야 한다.

fallback : false

만약 fallback이 false로 세팅되어 있다면 경로가 세팅되어 있지 않은 페이지를 방문하면 404를 방문한다.
getStaticPaths에 의하여 빌드타임에 생성된 path만 취급하기 때문이다. 만약 적은 수의 path를 생성할 때에는 false로 생성해두어도 별 상관이 없을 것이다.

fallback을 false로 생성해 두었다면 새로운 페이지를 추가하고 싶을 때 무조건 build를 다시 수행해야 한다.

그리고 getStaticPaths 함수는 getStaticProps와 무조건 짝꿍으로 쓰여야 하는데, 아래 예시를 보자.

// pages/posts/[id].js

function Post({ post }) {
}

export async function getStaticPaths() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()
  const paths = posts.map((post) => ({
    params: { id: post.id },
  }))
  return { paths, fallback: false }
}

export async function getStaticProps({ params }) {
  const res = await fetch(`https://.../posts/${params.id}`)
  const post = await res.json()

  return { props: { post } }
}

export default Post

두 함수는 모두 빌드타임에 만들어지고, getStaticPaths에 의하여 posts의 정보들이 불려와진다. 그리고 해당 id에 해당하는 페이지를 미리 만들어둔다.
이후 getStaticProps를 통하여 post정보를 받아오게 한다.

fallback: true

getStaticPaths에 의해 만들어진 경로들은 getStaticProps에 의하여 빌드 타임에 HTML을 만든다.
만약, 아직 만들어지지 않은 페이지라면 최종적으로 404 페이지를 리턴하게 된다.
fallback: true를 이용하면 아직 로드되지 않은 페이지가 있을때 HTML이나 json파일을 만든다. 이 때 바로 getStaticProps 함수가 열일하여서 만들어 내는 것이다.
만약 해당 작업이 완료된다면 브라우저는 생성된 path로 갈 수 있는 JSON 파일을 받는다. 이것을 통하여 페이지를 정상적으로 랜더링 할 수 있다.
이 작업이 끝남과 동시에 pre-render된 리스트에 해당 페이지가 추가되기 때문에 처음에 getStaticProps의 동작을 불러온 사람만 기다리게 되고 다음 사람부터는 pre-render된 페이지로 빠르게 페이지를 볼 수 있게 된다.

fallback: true의 이점

  • 새로 생성되는 데이터나, DB에 의존하여 많은 정적인 페이지를 가지고 있을 때 필요하다. (전체 페이지를 pre-render하는 것은 많은 시간이 걸리기 때문이다.)
  • 요청된 데이터를 통하여 페이지를 완성하고 나면 pre-render 페이지 목록에 추가하여 다음에 해당 페이지를 요청하면 매우 빠른 속도로 볼 수 있다.

fallback : 'blocking'

getStaticPaths를 통하여 리턴되는 새로운 페이지가 없을 때 기다리는 동안 HTML 파일을 보여주지 않는다. 말 그대로 로딩하는 중간에 모든 UI가 멈춘다고 생각하면 편하다.
나머지 동작은 fallback:true와 매우 유사하다.

이제 공식문서의 내용을 통하여 개념을 알아보았으니 우리의 서비스에 이 기능을 적용해보도록 하자.

const StoreDetail: NextPage<Props> = ({store}) => {
    const expanded = true;
    const router = useRouter();
    if(router.isFallback){
        return <div>Loading...</div>;
    }
    return (
        <div className = {`${styles.detailSection} ${styles.expanded} ${styles.selected}}`}>
           <DetailHeader
            currentStore={store}
            expanded={expanded}
            onClickArrow={() => null}
            />
            <DetailContent currentStore = {store} expanded = {expanded}/>
        </div>
    );
    
}

만약 routerFallback 상태가 true라면, getStaticProps를 이용하는 도중에는 Loading html을 띄울 수 있다.
그리고 getStaticProps에서 찾는 것이 없다면 notfound:true 상태를 반환하도록 한다.

export const getStaticProps: GetStaticProps = async ({params}) => {
    const stores = (await import ('../public/stores.json')).default;
    const store = stores.find((store) => store.name === params?.name);
    if(!store){
        return {
            notFound: true, //404 page
        };
    }

    return { props: {store} };
}

그럼 이렇게 올바른 경로에 들어가지 않을 때에는 404페이지를 띄울 수 있도록 404.tsx를 만들어두자.

export default function Custom404() {
    return <h1>해당 매장을 찾을 수 없습니다.</h1>
}

짧게나마라도 Loading이 뜬 것을 볼 수 있을 것이다. ㅎㅎ
이것은 fallback 상태를 true로 해두었기 때문에 볼 수 있는 것이다.
하지만 우리의 애플리케이션은 정말 적은 정도의 json 데이터만 이용하고 있기 때문에 fallback을 false로 설정해두자!

Detail UI 닫기

detail Ui의 토글 버튼을 닫으면 해당 식당에 대한 정보가 뜨고 지도의 중앙으로 정보가 이동될 수 있게끔 수정하고자 한다.

const StoreDetail: NextPage<Props> = ({store}) => {
    const expanded = true;
    const router = useRouter();
    const { setCurrentStore } = useCurrentStore();
  
    const goToMap = () => {
      setCurrentStore(store);
      router.push(`
        /?zoom=15&lat=${store.coordinates[0]}&lng=${store.coordinates[1]}
      `);
    };
    return (
        <div className = {`${styles.detailSection} ${styles.expanded} ${styles.selected}}`}>
           <DetailHeader
            currentStore={store}
            expanded={expanded}
            onClickArrow={goToMap}
            />
            <DetailContent currentStore = {store} expanded = {expanded}/>
        </div>
    );
    
}

goTo라는 함수를 구현할 수 있는데, 현재 선택된 store를 detail페이지의 store라고 설정하고, 해당 라우터로이동하여 지도를 볼 수 있게끔 한다.
만들어 두었던 useCurrentStore라는 훅이 두고두고 잘 사용되고 있다. ㅎㅎ

이렇게 해서 만들고자 하는 대부분의 기능과 UI 구현이 끝이 났다!

0개의 댓글