DEVELOP
article thumbnail

인프런 '박용주 지식 공유자님' 의 [Next.js 시작하기] 강의를 수강하고 작성한 게시글입니다.

 

 

Next.js 시작하기(feat. 지도 서비스 개발) - 인프런 | 강의

Next.js의 기본을 다루는 강의입니다. Next.js로 지도 서비스를 처음부터 끝까지 개발해봅니다., - 강의 소개 | 인프런...

www.inflearn.com


DetailSection

detailSection 추가하기

▼ myapp/components/home/MapSection.tsx

더보기
더보기
import styles from '../../styles/detail.module.scss';
import { IoIosArrowUp } from 'react-icons/io';

const DetailSection = () => {
    return (
        <div className={styles.detailSection}>
            <div className={styles.header}>
                <button className={styles.arrowButton} disabled>
                    <IoIosArrowUp size={20} color="#666666" />
                </button>
                <p className={styles.title}>매장을 선택해주세요</p>
            </div>
        </div>
    );
};
export default DetailSection;

▼ myapp.styles/detail.module.scss

  • ▽ translateY 로 화면에 하단에 detailSection이 위치하도록 함 
더보기
더보기
$header-height: 60px;
$section-padding-top: 8px;

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

    padding: $section-padding-top 16px 16px;
    background-color: white;
    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})
    );
}
.header {
    height: $header-height;

    display: flex;
    flex-direction: column;

    .arrowButton {
        height: 20px;
        align-self: center;

        border: none;
        background-color: transparent;
        &:disabled {
            opacity: 0.2;
            cursor: not-allowed;
        }
    }
}

.title {
    margin: 4px 0;
    font-size: 1rem;
    font-weight: 500;
}

▼ myapp/pages/index.tsx

  • MapSection 추가하고, main에 position 속성 "relative" 추가
    return (
        <Fragment>
            <Header />
            <main
                style={{ position: 'relative', width: '100%', height: '100%' }}
            >
                <MapSection />
                <DetailSection />
            </main>
        </Fragment>
    );

네이버 로고 detailSection 위에 위치하기

▼ myapp/components/home/Map.tsx

  • 네이버 로고에 스타일 적용하기 위해서 styles import 
import styles from '../../styles/map.module.scss';
            <div id={mapId} className={styles.map} />

▼ myapp/styles/map.module.scss

  • ▽ 네이버 로고가 map 컴포넌트의 두번째 div이므로 & > div:nth-of-type(2) 에 detailSection의 header-height값과 section-padding-top 값을 더한 것을 bottom 값으로 줌 
  •  !important 속성을 추가해 강제로 값을 변경함 
@use './detail.module.scss';
.map {
    width: 100%;
    height: 100%;

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

하단에 detailSection 추가


DetailSection 애니메이션 구현하기

매장 선택 시 매장 이름 표시하기 

▼ myapp/components/home/MapSection.tsx

    const { data: currentStore } = useSWR(CURRENT_STORE_KEY);
                {!currentStore && (
                    <p className={styles.title}>매장을 선택해주세요</p>
                )}
                {currentStore && (
                    <p className={styles.title}>{currentStore.name}</p>
                )}

매장 클릭 시 하단에 이름 보여짐


DetailSection 펼치기 / 접기

▼ myapp/components/home/MapSection.tsx

  • ▽ detailSection이 확장되어있는지를 구분하는 state 'expanded' 생성  
  const [expanded, setExpanded] = useState(false);
  •   detailSection의 가장 부모 div 태그에 className 추가 
    • ▷ 현재 선택된 매장이 있으면 styles.selceted를 추가한다.
    • ▷ detailSecton이 확장 되어있으면 styles.expanded 를 추가한다.
    <div
      className={`${styles.detailSection} ${
        currentStore ? styles.selected : ''
      } ${expanded ? styles.expanded : ''}`}
    >
  •   화살표 버튼의 className을 추가한다.
    • detailSecton이 확장 되어있으면 styles.expanded 를 추가한다.
  • ▽ 버튼을 클릭했을 때 expanded 값이 true이면 false로, false이면 true로 바꾼다.
  • ▽ 현재 선택된 매장이 없으면 클릭이 안되도록 disabled 속성을 설정한다. 
        <button
          className={`${styles.arrowButton} ${expanded ? styles.expanded : ''}`}
          onClick={() => setExpanded(!expanded)}
          disabled={!currentStore}
        >
          <IoIosArrowUp size={20} color="#666666" />
        </button>

▼ myapp/styles/detail.module.scss

  • ▽ 매장이 선택된 상태면 160px만큼 detailSection이 올라오도록 한다.
  • ▽ detailSection이 확장된 상태일 때는 끝까지 올라오도록 한다. 
// .detailSection
  transition: transform 800ms;
  transform: translateY(
    calc(100% - #{$header-height} - #{$section-padding-top})
  );
  &.selected {
    transform: translateY(calc(100% - 160px));
  }
  &.expanded {
    transform: translateY(0);
  }
  • ▽ detailSection이 확장된 상태이면 화살표 버튼이 반대로 (접는 모양)으로 바꾼다.
//  .arrowButton
    &.expanded {
      transform: rotate(180deg);
    }

detailSection 접기 / 펼치기

 


펼치기/접기 버튼에 애니메이션 추가하기

▼ myapp/styles/detail.module.scss

  • ▽ 버튼에 bounce 애니메이션을 정의하고 추가하여 위아래로 왔다갔다하도록 한다.
// .arrowButton
  @keyframes bounce {
      from {
        transform: translateY(0);
      }
      to {
        transform: translateY(-3px);
      }
    }
    svg {
      animation: bounce 600ms infinite alternate ease-in;
    }

화살표 버튼에 애니메이션 추가


DetailSection UI 완성하기

reset CSS 코드 추가

▼ myapp/styles/globals.scss

더보기
더보기
/* http://meyerweb.com/eric/tools/css/reset/
   v2.0 | 20110126
   License: none (public domain)
*/
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
section,
summary,
time,
mark,
audio,
video {
  margin: 0;
  padding: 0;
  border: 0;
  vertical-align: baseline;
}

input,
textarea,
button,
select,
a {
  -webkit-tap-highlight-color: transparent;
}

/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
  display: block;
}

ol,
ul {
  list-style: none;
}

button {
  cursor: pointer;
  font-family: inherit;
}

이미지 domain 추가하기

- next.config.js에서 이미지 url의 domain을 추가해주어야 한다.

▼ myapp/next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    domains: ['lecture-1.vercel.app', 'search.pstatic.net'],
  },
  reactStrictMode: true,
};

module.exports = nextConfig;

- 위처럼 domain을 추가해주어도 error가 발생하여 Image 태그에 unoptimized 속성을 true로 설정해주었다.

▼ myapp/components/home/DetailContent.tsx

            <Image
              src={image}
              alt=""
              fill
              style={{ objectFit: 'cover' }}
              unoptimized={true}
              placeholder="blur"
              blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO0WhFsDwADzwF2mLYSJgAAAABJRU5ErkJggg=="
            />

DetailContent 컴포넌트

▼ myapp/components/home/DetailSection.tsx

  • ▽ DetailContent 컴포넌트에 props로 현재 매장 정보(currentStore)와 확장 여부(expanded)를 전달하여 호출한다,
      <DetailContent currentStore={currentStore} expanded={expanded} />

▼ myapp/components/home/DetailContent.tsx

더보기
더보기
import { Store } from '@/types/store';
import { IoCallOutline, IoLocationOutline } from 'react-icons/io5';
import Naver from 'public/imges/naver.png';
import styles from '../../styles/detail.module.scss';
import Image from 'next/image';
type Props = {
  currentStore?: Store;
  expanded: boolean;
};
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
            key={image}
            style={{ position: 'relative', maxWidth: 120, height: 80 }}
          >
            <Image
              src={image}
              alt=""
              fill
              style={{ objectFit: 'cover' }}
              unoptimized={true}
              placeholder="blur"
              blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO0WhFsDwADzwF2mLYSJgAAAABJRU5ErkJggg=="
            />
          </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>
  );
};
export default DetailContent;

▼ myapp/styles/detail.module.scss

더보기
더보기
.detailContent {
  height: 100%;
  overflow: hidden;
  &.expanded {
    overflow: scroll;
    &::-webkit-scrollbar {
      display: none;
    }
  }
  .images {
    display: grid;
    grid-template-columns: repeat(3, minmax(auto, 120px));
    justify-content: center;
    gap: 12px;
    margin-bottom: 16px;
  }

  h2 {
    font-size: 1.125rem;
    font-weight: 600;
    margin: 8px 0;
  }

  hr {
    border-bottom: none;
    border-top: 1px solid #eef0f3;
    margin: 16px 0;
  }

  .description {
    p {
      margin: 4px 0;
    }
  }

  .basicInfo {
    div {
      display: flex;
      align-items: center;
      margin-bottom: 8px;

      span {
        margin-left: 4px;
        font-size: 1rem;
      }

      a {
        color: #64c0a9;
      }
    }
  }

  .menus {
    .menu {
      display: flex;
      justify-content: space-between;
      margin-top: 16px;

      .name {
        max-width: 70%;
        word-break: keep-all;
      }
    }
  }
}


getStaticProps & getStaticPaths

getStaticProps

: 빌드 시 데이터를 fetch하여 static 페이지를 생성한다.

export async function getStaticProps(context) {
  return {
    props: {}, // will be passed to the page component as props
  }
}

getStaticPaths

: 동적 라우팅 페이지 중, 빌드 시에 static하게 생성할 페이지를 미리 정한다.

  • params 안의 값에 대해서만 미리 페이지를 렌더링한다.
// pages/posts/[id].js

// Generates `/posts/1` and `/posts/2`
export async function getStaticPaths() {
  return {
    paths: [{ params: { id: '1' } }, { params: { id: '2' } }],
    fallback: false, // can also be true or 'blocking'
  }
}

// `getStaticPaths` requires using `getStaticProps`
export async function getStaticProps(context) {
  return {
    // Passed to the page component as props
    props: { post: {} },
  }
}

export default function Post({ post }) {
  // Render post...
}

fallback 값에 따른 비교

fallback : false

  • getStaticPaths에서 리턴하지 않은 페이지는 모두 404로 연결한다.

바로 404페이지 연결

fallback : true

  • getStaticPath에서 리턴하지 않은 페이지 접속했을 때,
  1. 사용자에게 fallback 페이지를 보여준다.
  2. 서버에서 static하게 페이지를 생성한다.
  3. 해당 페이지를 사용자에게 보여준다.
  4. 첫 사용자 이후 다음부터 접속하는 사용자에게는 static한 페이지를 보여준다.
  • 많은 static 페이지를 생성해야 하지만 빌드 시간이 너무 오래 걸릴 경우 사용한다. 
  • getStaticProps에서 props가 없을 경우를 정의해야하고,
    화면 리턴 시 라우터의 fallback 여부를 확인하여 Loading 문구를 띄워주어야 한다.

로딩 후 404 페이지 연결

fallback : blocking

  • getStaticPaths에서 리턴하지 않은 페이지 접속했을 때,
  1. 사용자에게 fallback 페이지나 로딩 화면 없이 server side renering한 static 페이지를 보여준다. 
  2. 첫 사용자 이후 다음부터 접속하는 사용자에게는 server side rendering한 페이지를 보여준다.
  • getStaticProps 함수가 return될 때까지 UI를 가만히 blocking 함 
  • 동적 라우팅 페이지를 static 페이지로 제공해야 할 때 사용한다. 

fallback UI 없이 바로 404 페이지 연결


각 매장의 상세 페이지 연결하기

  • 동적 페이지를 생성하기 위해서는 pages 디렉토리에 [id].tsx 형식의 파일을 생성해야 한다. (id 값은 props) 

▼ pages/[name].tsx

import { Store } from '@/types/store';
import { GetStaticPaths, GetStaticProps, NextPage } from 'next';

interface Props {
  store: Store;
}
const StoreDetail: NextPage<Props> = ({ store }) => {
  return <div>{store.name}</div>;
};

export default StoreDetail;

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: false };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const stores = (await import('public/stores.json')).default;
  const store = stores.find((store) => store.name === params?.name);

  return { props: { store } };
};


404 페이지 custom 하기

  • 404 페이지를 custom 하려면 pages 디렉토리에 404.tsx 를 생성해야 한다. 

▼ myapp/pages/404.tsx

const Custom404 = () => {
  return (
    <div style={{ textAlign: 'center' }}>해당 매장을 찾을 수 없습니다.</div>
  );
};

export default Custom404;

custom404


상세 페이지 UI 구현하기

DetailSection의 Header 분리 & 공유 기능 추가하기

▼ myapp/components/home/DetailHeader.tsx

  • ▽ Header를 분리하고, 공유 버튼을 추가한다.
  • ▽ 공유 버튼을 클릭하면 클립보드에 현재 url 이 복사되도록 한다. 
더보기
더보기
import { Store } from '@/types/store';
import copy from 'copy-to-clipboard';
import { AiOutlineShareAlt } from 'react-icons/ai';
import { IoIosArrowUp } from 'react-icons/io';
import styles from '../../styles/detail.module.scss';
import headerStyles from '../../styles/header.module.scss';

interface Props {
  currentStore?: Store;
  expanded: boolean;
  onClickArrow: () => void;
}
const DetailHeader = ({ currentStore, expanded, onClickArrow }: Props) => {
  return (
    <div className={styles.header}>
      <button
        className={`${styles.arrowButton} ${expanded ? styles.expanded : ''}`}
        onClick={onClickArrow}
        disabled={!currentStore}
      >
        <IoIosArrowUp size={20} color="#666666" />
      </button>
      {!currentStore && <p className={styles.title}>매장을 선택해 주세요.</p>}
      {currentStore && (
        <div className={styles.flexRow}>
          <p className={styles.title}>{currentStore.name}</p>
          <button
            className={headerStyles.box}
            onClick={() => {
              copy(location.origin + '/' + currentStore.name);
            }}
          >
            <AiOutlineShareAlt size={20} />
          </button>
        </div>
      )}
    </div>
  );
};

export default DetailHeader;

공유버튼추가

상세페이지 UI 구현하기

▼ myapp/pages/[name].tsx

  • ▽ detailSection과 똑같이 구현하되, 접기 화살표 버튼 클릭 시 현재 선택된 매장이 표시된 map으로 url을 이동한다. 
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}`}>
      <DetailHeader
        currentStore={store}
        expanded={expanded}
        onClickArrow={goToMap}
      />
      <DetailContent currentStore={store} expanded={expanded} />
    </div>
  );
};

접기 버튼 클릭 시 현재 매장 map으로 이동

profile

DEVELOP

@JUNGY00N