[프로그래머스] 프론트엔드 심화: 프로젝트(10)

Lina Hongbi Ko·2024년 11월 21일
0

Programmers_BootCamp

목록 보기
61/76
post-thumbnail

2024년 11월 21일

✏️ 메인화면 - 리뷰 섹션

  • 메인 화면에 mock 리뷰 띄우기
// mock / review.ts

... 생략 ...

export const reviewForMain = http.get("http://127.0.0.1:9999/reviews", () => {
  return HttpResponse.json(mockReviewsData, {
    status: 200,
  });
});
// mock / browser.ts

import { setupWorker } from "msw/browser";
import { addReview, reviewForMain, reviewsById } from './review';

const handlers = [reviewsById, addReview, reviewForMain];

export const worker = setupWorker(...handlers);
// api / review.api.ts

... 생략 ...

export const fetchReviewAll = async () => {
  return await requestHandler<BookReviewItem>("get", "/reviews");
}

... 생략 ...
// hooks / useMain.ts

import { fetchReviewAll } from '@/api/review.api';
import { BookReviewItem } from '@/models/book.model';
import { useEffect, useState } from 'react'

export const useMain = () => {
  const [reviews, setReviews] = useState<BookReviewItem[]>([]);

  useEffect(()=>{
    fetchReviewAll().then((reviews)=> {
      setReviews(reviews);
    })
  },[]);

  return { reviews };
}
// pages / Home.tsx

import MainReview from '@/components/main/HomeReview';
import { useMain } from '@/hooks/useMain';
import styled from 'styled-components';

const Home = () => {
  const { reviews } = useMain();
  return (
    <HomeStyle>
      {/* 배너 */}
      {/* 베스트셀러 */}
      {/* 신간 */}
      {/* 리뷰 */}
      <MainReview reviews={reviews} />
    </HomeStyle>
  );
}

const HomeStyle = styled.div`
  color: ${({ theme }) => theme.colors.primary};
`;

export default Home;
// components / main / HomeReview.tsx

import { BookReviewItem as IBookReviewItem} from '@/models/book.model';
import styled from 'styled-components';
import BookReviewItem from '../book/BookReviewItem';

interface Props {
  reviews: IBookReviewItem[];
}

const MainReview = ({reviews}: Props) => {
  return (
    <MainReviewStyle>
      {
        reviews.map((review)=> (
          <BookReviewItem key={review.id} review={review} />
        ))
      }
    </MainReviewStyle>
  );
}

const MainReviewStyle = styled.div``;

export default MainReview;
  • react-slick 라이브러리를 이용해서 틀 만들어주기

    • react-slick

      • 설치
        • npm install react-slick —save
        • npm install slick-carousel —save
        • npm install —save-dev @types/react-slick
    • 슬라이드 임포트

      // components / main / HomeReview.tsx
      
      import { BookReviewItem as IBookReviewItem} from '@/models/book.model';
      import styled from 'styled-components';
      import BookReviewItem from '../book/BookReviewItem';
      import Slider from 'react-slick';
      
      import "slick-carousel/slick/slick.css";
      import "slick-carousel/slick/slick-theme.css";
      
      interface Props {
        reviews: IBookReviewItem[];
      }
      
      const MainReview = ({reviews}: Props) => {
        const sliderSettings = {
          dots : true,
          infinite: true,
          speed: 500,
          slidesToShow: 3,
          slidesToScroll: 3,
          gap: 16
        }
        return (
          <MainReviewStyle>
            <Slider {...sliderSettings}>
              {
                reviews.map((review)=> (
                  <BookReviewItem key={review.id} review={review} />
                ))
              }
            </Slider>
          </MainReviewStyle>
        );
      }
      
      const MainReviewStyle = styled.div`
        padding: 0 0 24px 0;
      
        .slick-track {
          padding: 12px 0;
        }
      
        .slick-slide > div {
          margin: 0 12px;
        }
      
        .slick-prev:before,
        .slick-next:before {
          color: #000;
        }
      `;
      
      export default MainReview;

✏️ 메인화면 - 신간 섹션

// hooks / useMain.ts

... 생략 ...

const [newBooks, setNewBooks] = useState<Book[]>([]);

... 생략 ...

 fetchBooks({
      category_id: undefined,
      news: true,
      currentPage: 1,
      limit: 4,
    }).then(({books})=> setNewBooks(books));

  },[]);

  return { reviews, newBooks };
}
// pages / Home.tsx

... 생략 ...

      <section className="sec">
        <Title size="large">신간 안내</Title>
        <MainNewBooks books={newBooks} />
        
        ... 생략 ...
// components / main / MainNewBooks.tsx

import { Book } from '@/models/book.model';
import styled from 'styled-components';
import BookItem from '../books/BookItem';

interface Props {
  books: Book[];
}

const MainNewBooks = ({books}: Props) => {
  return (
    <MainNewBooksStyle>
      {
        books.map((book)=>(<BookItem key={book.id} book={book} view="grid" />))
      }
    </MainNewBooksStyle>
  );
}

const MainNewBooksStyle = styled.div`
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
`;

export default MainNewBooks;

✏️ 메인화면 - 베스트 섹션

// mock / books.ts

import { Book } from '@/models/book.model';
import { http, HttpResponse } from "msw";
import { fakerKO as faker } from "@faker-js/faker";

const bestBooksData: Book[] = Array.from({length: 10}).map((item, index)=>(
  {
    id: index,
    title: faker.lorem.sentence(),
    img: (faker.helpers as any).rangeToNumber({ min: 100, max: 200}),
    category_id: (faker.helpers as any).rangeToNumber({ min: 0, max: 2 }),
    form: "종이책",
    isbn: faker.commerce.isbn(),
    summary: faker.lorem.paragraph(),
    detail: faker.lorem.paragraph(),
    author: faker.person.firstName(),
    pages: (faker.helpers as any).rangeToNumber({ min: 100, max: 500 }),
    contents: faker.lorem.paragraph(),
    price: (faker.helpers as any).rangeToNumber({ min: 10000, max: 50000 }),
    likes: (faker.helpers as any).rangeToNumber({ min: 0, max: 100 }),
    pub_date: faker.date.past().toISOString(),
  }
))

export const bestBooks = http.get("http://127.0.0.1:9999/books/best", () => {
  return HttpResponse.json(bestBooksData, {
    status: 200
  })
})
// mock / browser.ts

import { setupWorker } from "msw/browser";
import { addReview, reviewForMain, reviewsById } from './review';
import { bestBooks } from './books';

const handlers = [reviewsById, addReview, reviewForMain, bestBooks];

export const worker = setupWorker(...handlers);
// api / books.api.ts

... 생략 ...


export const fetchBestBooks = async () => {
  const response = await httpClient.get<Book[]>(`/books/best`);
  return response.data;
}
// hooks / useMain.ts

... 생략 ...

	  const [bestBooks, setBestBooks] = useState<Book[]>([]);
	  
	  ... 생략 ...
	  
	  fetchBestBooks().then((books)=> setBestBooks(books));

  },[]);

  return { reviews, newBooks, bestBooks };
}
// pages / Home.tsx

... 생략 ...

			{/* 베스트셀러 */}
      <section className="sec">
        <Title size="large">베스트 셀러</Title>
        <MainBest books={bestBooks} />
      </section>
      
      ... 생략 ...
// components / main / mainBest.tsx

import { Book } from '@/models/book.model';
import styled from 'styled-components';
import BookBestItem from '../books/BookBestItem';

interface Props {
  books: Book[];
}

const MainBest = ({books}: Props) => {
  return (
    <MainBestStyle>
      {
        books.map((item, index)=> (
        <BookBestItem key={item.id} book={item} itemIndex={index} />))
      }
    </MainBestStyle>
  );
}

const MainBestStyle = styled.div`
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 12px;
`;

export default MainBest;
// components / books / BookBestItem.tsx

import { Book } from '@/models/book.model';
import styled from 'styled-components';
import BookItem, { BookItemStyle } from './BookItem';

interface Props {
  book: Book;
  itemIndex: number;
}

const BookBestItem = ({book, itemIndex}: Props) => {
  return (
    <BookBestItemStyle>
      <BookItem book={book} view="grid" />
      <div className="rank">
        {itemIndex + 1}
      </div>
    </BookBestItemStyle>
  );
}

const BookBestItemStyle = styled.div`
  ${BookItemStyle} {
    .summary,
    .price,
    .likes {
      display: none;
    }
    
    h2 {
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
      text-overflow: ellipsis;
    }
  }

  position: relative;

  .rank {
    position: absolute;
    top: -10px;
    left: -10px;
    width: 40px;
    height: 40px;
    background: ${({theme}) => theme.colors.primary};
    border-radius: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 1.25rem;
    color: #fff;
    font-weight: 700;
    font-stye: italic;
  }
`;

export default BookBestItem;

✏️ 메인화면 - 배너 섹션 (1)

// models / bannel.model.ts

export interface Banner {
  id: number;
  title: string;
  description: string;
  image: string;
  url: string;
  target: string;
}
// mock / banner.ts

import { http, HttpResponse } from "msw";
import { fakerKO as faker } from "@faker-js/faker";
import { Banner } from '@/models/bannel.model';

const bannersData: Banner[] = [
  {
    id: 1,
    title: "배너 1 제목",
    description: "Banner 1 description",
    image: "https://picsum.photos/id/111/1200/400",
    url: "http://some.url",
    target: "_blank",
  },
  {
    id: 2,
    title: "배너 2 제목",
    description: "Banner 2 description",
    image: "https://picsum.photos/id/222/1200/400",
    url: "http://some.url",
    target: "_self",
  },
  {
    id: 3,
    title: "배너 3 제목",
    description: "Banner 3 description",
    image: "https://picsum.photos/id/33/1200/400",
    url: "http://some.url",
    target: "_blank",
  }
]

export const banners = http.get("http://127.0.0.1:9999/banners", () => {
  return HttpResponse.json(bannersData, {
    status: 200,
  })
})
// mock /browser.ts

import { setupWorker } from "msw/browser";
import { addReview, reviewForMain, reviewsById } from './review';
import { bestBooks } from './books';
import { banners } from './banner';

const handlers = [reviewsById, addReview, reviewForMain, bestBooks, banners];

export const worker = setupWorker(...handlers);
// api / banner.api.ts

import { Banner } from '@/models/bannel.model';
import { requestHandler } from './http';

export const fetchBanners = async () => {
  return await requestHandler<Banner[]>("get", "/banners");
}
// hooks / useMain.ts

const [banners, setBanners] = useState<Banner[]>([]);

... 생략 ...

		  fetchBanners().then((banners)=> setBanners(banners));

  },[]);

  return { reviews, newBooks, bestBooks, banners };
}
// pages / Home.tsx

... 생략 ...

const Home = () => {
  const { reviews, newBooks, bestBooks, banners} = useMain();
  return (
    <HomeStyle>
      {/* 배너 */}
      <Banner banners={banners} />
      
      ... 생략 ...
// components / banner / Banner.tsx

import { Banner as IBanner} from '@/models/bannel.model';
import styled from 'styled-components';
import BannerItem from './BannerItem';

interface Props {
  banners: IBanner[];
}

const Banner = ({banners}: Props) => {
  return (
    <BannerStyle>
      {
        banners.map((item, index)=>(
          <BannerItem key={item.id} banner={item} />
        ))
      }
    </BannerStyle>
  );
}

const BannerStyle = styled.div``;

export default Banner;
// components / banner / BannerItem.tsx

import { Banner as IBanner} from '@/models/bannel.model';
import styled from 'styled-components';

interface Props {
  banner: IBanner;
}

const BannerItem = ({banner}: Props) => {
  return (
    <BannerItemStyle>
      <div className="img">
        <img src={banner.image} alt={banner.title} />
      </div>
      <div className="content">
        <h2>{banner.title}</h2>
        <p>{banner.description}</p>
      </div>
    </BannerItemStyle>
  );
}

const BannerItemStyle = styled.div`
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  position: relative;

  .img {
    width: 100%;
    max-width: 100%;
  }

  .content {
    position: absolute;
    top: 0;
    left: 0;
    width: 40%;
    height: 100%;
    background: linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));

    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;

    h2 {
      font-size: 2rem;
      font-eight: 700;
      margin-bottom: 1rem;
      color: ${({theme})=> theme.colors.primary};
    }

    p {
      font-size: 1.2rem;
      color: ${({theme})=> theme.colors.text};
      margin: 0;
    }
  }
`;

export default BannerItem;

✏️ 메인화면 - 배너 섹션 (2)

// components / banner / Banner.tsx

import { Banner as IBanner} from '@/models/bannel.model';
import styled from 'styled-components';
import BannerItem from './BannerItem';
import { useMemo, useState } from 'react';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';

interface Props {
  banners: IBanner[];
}

const Banner = ({banners}: Props) => {
  const [currentIndex, setCurrentIndex] = useState(0);

  const transFormValue = useMemo(()=>{
    return currentIndex * -100;
  }, [currentIndex]);
  
  const handlePrev = () => {
    if(currentIndex === 0) return;
    setCurrentIndex(currentIndex - 1);
  };
  const handleNext = () => {
    if(currentIndex === banners.length - 1) return;
    setCurrentIndex(currentIndex + 1);
  };

  const handleIndicatorClick = (index:number) => {
    setCurrentIndex(index);
  }

  return (
    <BannerStyle>
      {/* 배너 그룹 */}
      <BannerContainerStyle $transFormValue={transFormValue}>
        {
          banners.map((item, index)=>(
            <BannerItem key={item.id} banner={item} />
          ))
        }
      </BannerContainerStyle>

      {/* 버튼 */}
      <BannerButtonStyle>
        <button className="prev" onClick={handlePrev}><FaAngleLeft /></button>
        <button className="next" onClick={handleNext}><FaAngleRight /></button>
      </BannerButtonStyle>

      {/* 인디케이터 */}
      <BannerIndicatorStyle>
        {
          banners.map((banner, index)=> (<span className={
            index === currentIndex ? "active" : ""
          } onClick={()=>{handleIndicatorClick(index)}}></span>))
        }
      </BannerIndicatorStyle>

    </BannerStyle>
  );
}

const BannerStyle = styled.div`
  overflow: hidden;
  position: relative;
`;

interface BannerContainerStyleProps {
  $transFormValue: number;
}

const BannerContainerStyle = styled.div<BannerContainerStyleProps>`
  display: flex;
  transform: translateX(${(props) => props.$transFormValue}%);
  transition: transform 0.5s ease-in-out;
`;

const BannerButtonStyle = styled.div`
  button {
    border: 0;
    width: 40px;
    height: 40px;
    background: rgba(0, 0, 0, 0.5);
    border-radius: 100%;
    font-size: 2rem;
    cursor: pointer;
    display: flex;
    justify-content: center;
    align-items: center;
    position: absolute;
    top: 50%;
    transform: translateY(-50%);

    svg {
      fill: #fff;
    }

    &.prev {
      left: 10px;
    }

    &.next {
      right: 10px;
    }
  }
`;

const BannerIndicatorStyle = styled.div`
  position: absolute;
  bottom: 10px;
  left: 50%;
  transform: translateX(-50%);

  span {
    display: inline-block;
    width: 16px;
    height: 16px;
    border-radius: 100%;
    background: #fff;
    margin: 0 4px;
    cursor: pointer;

    &.active {
      background: ${({theme}) => theme.colors.primary};
    }
  }
`;

export default Banner;
// components / banner / BannerItem.tsx

import { Banner as IBanner} from '@/models/bannel.model';
import styled from 'styled-components';

interface Props {
  banner: IBanner;
}

const BannerItem = ({banner}: Props) => {
  return (
    <BannerItemStyle>
      <div className="img">
        <img src={banner.image} alt={banner.title} />
      </div>
      <div className="content">
        <h2>{banner.title}</h2>
        <p>{banner.description}</p>
      </div>
    </BannerItemStyle>
  );
}

const BannerItemStyle = styled.div`
  flex: 0 0 100%;

  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  position: relative;

  .img {
    img {
      width: 100%;
      max-width: 100%;
    }
  }

  .content {
    position: absolute;
    top: 0;
    left: 0;
    width: 40%;
    height: 100%;
    background: linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));

    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;

    h2 {
      font-size: 2rem;
      font-eight: 700;
      margin-bottom: 1rem;
      color: ${({theme})=> theme.colors.primary};
    }

    p {
      font-size: 1.2rem;
      color: ${({theme})=> theme.colors.text};
      margin: 0;
    }
  }
`;

export default BannerItem;

✏️ 모바일 대응 (1)

  • 모바일 대응시필요한 개념
    • viewport
    • 상대값을 가진 레이아웃
    • 화면 너비 감지 (mediaquery)
// hooks / useMediaQuery.ts

import { getTheme } from '@/style/theme';
import { useEffect, useState } from 'react'

export const useMediaQuery = () => {
  const [isMobile, setIsMobile] = useState(window.matchMedia(getTheme("light").mediaQuery.mobile).matches);

  useEffect(()=>{
    const isMobileQuery = window.matchMedia(getTheme("light").mediaQuery.mobile);

    setIsMobile(isMobileQuery.matches);
  }, []);

  return { isMobile };
}
// style / theme.ts

... 생략 ...
export type MediaQuery = "mobile" | "tablet" | "desktop";

... 생략 ...

	mediaQuery: {
    [key in MediaQuery] : string;
  }
}

	... 생략 ...
	
	mediaQuery: {
    mobile: "(max-width: 768px)", // 768px 이하에서 동작
    tablet: "(max-width: 1024px)", // 1024px 이하에서 동작
    desktop: "(min-width: 1025px)" // 1025px 이상에서 동작
  }
};
// pages / Header.tsx

... 생략 ...

const [ isMobileOpen, setIsMobileOpen ] = useState(false);

return (
    <HeaderStyle $isOpen={isMobileOpen}>
    
    ... 생략 ...
    
    <nav className="category">
        <button className="menu-button" onClick={() => setIsMobileOpen(!isMobileOpen)}>
          { isMobileOpen ? <FaAngleRight /> : <FaBars />}
        </button>
        
        ... 생략 ...
        
        
        
interface HeaderStyleProps {
  $isOpen: boolean;
}

const HeaderStyle = styled.header<HeaderStyleProps>`

			... 생략 ...
			
			@media screen AND ${({theme}) => theme.mediaQuery.mobile} {

      height: 52px;

      .logo {
        padding: 0 0 0 12px;

        img {
          width: 60px;
        }
      }

      .auth {
        position: absolute;
        top: 12px;
        right: 12px;
      }

      .category {

        .menu-button {
          display: flex;
          position: absolute;
          top: 14px;
          right: ${({$isOpen}) => ($isOpen ? "60%" : "52px")};
          background: #fff;
          border: 0;
          font-size: 1.5rem;
        }

        ul {
          position: fixed;
          top: 0;
          right: ${({$isOpen}) => ($isOpen ? "0" : "-100%")};
          width: 60%;
          height: 100vh;
          background: #fff;
          box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
          transition: right 0.3s ease-in-out;

          margin: 0;
          padding: 24px;
          z-index: 1000;

          flex-direction: column;
          gap: 16px;

          li {
            a {
              font-size: 1.1rem;
            }
          }
        }
      }
    }
`;

export default Header;

✏️ 모바일 대응 (2)

// components / common / Header.tsx

... 생략 ...

	  const [ isMobileOpen, setIsMobileOpen ] = useState(false);
	  
	  return (
    <HeaderStyle $isOpen={isMobileOpen}>
    
    ... 생략 ...
    
	     <nav className="category">
        <button className="menu-button" onClick={() => setIsMobileOpen(!isMobileOpen)}>
          { isMobileOpen ? <FaAngleRight /> : <FaBars />}
        </button>
        
        ... 생략 ...
        
interface HeaderStyleProps {
  $isOpen: boolean;
}

const HeaderStyle = styled.header<HeaderStyleProps>`

	.... 생략 ...
	
	
	@media screen AND ${({theme}) => theme.mediaQuery.mobile} {

      height: 52px;

      .logo {
        padding: 0 0 0 12px;

        img {
          width: 60px;
        }
      }

      .auth {
        position: absolute;
        top: 12px;
        right: 12px;
      }

      .category {

        .menu-button {
          display: flex;
          position: absolute;
          top: 14px;
          right: ${({$isOpen}) => ($isOpen ? "60%" : "52px")};
          background: #fff;
          border: 0;
          font-size: 1.5rem;
        }

        ul {
          position: fixed;
          top: 0;
          right: ${({$isOpen}) => ($isOpen ? "0" : "-100%")};
          width: 60%;
          height: 100vh;
          background: #fff;
          box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
          transition: right 0.3s ease-in-out;

          margin: 0;
          padding: 24px;
          z-index: 1000;

          flex-direction: column;
          gap: 16px;

          li {
            a {
              font-size: 1.1rem;
            }
          }
        }
      }
    }
`;

export default Header;
// components / MainBest.tsx

... 생략 ...


const MainBestStyle = styled.div`
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 12px;

    @media screen AND ${({theme}) => theme.mediaQuery.mobile} {
      grid-template-columns: repeat(2, 1fr);
    }
`;

export default MainBest;
// componenets / layout / Layout.tsx

... 생략 ...

const LayoutStyle = styled.main`
  width: 100%;
  margin: 0 auto;
  max-width: ${({theme}) => theme.layout.width.large};
  padding: 20px 0;

  @media screen AND ${({theme}) => theme.mediaQuery.mobile} {
    padding: 20px 12px;
  }
`;

export default Layout
// components / main / MainNewBooks.tsx


... 생략 ...

const MainNewBooksStyle = styled.div`
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;

  @media screen AND ${({theme}) => theme.mediaQuery.mobile} {
    grid-template-columns: repeat(2, 1fr);
  }
`;

export default MainNewBooks;
// components / main / MainReview.tsx

... 생략 ...

import { useMediaQuery } from '@/hooks/useMediaQuery';

... 생략 ...

const MainReview = ({reviews}: Props) => {
  const { isMobile } = useMediaQuery();
  const sliderSettings = {
    dots : true,
    infinite: true,
    speed: 500,
    slidesToShow: isMobile ? 1 : 3,
    slidesToScroll: isMobile ? 1 : 3,
    gap: 16
  }

  ... 생략 ...
  
  @media screen AND ${({theme}) => theme.mediaQuery.mobile} {
    .slick-prev {
      left: 0;
    }
    .slick-next {
      right: 0;
    }
  }
// components / common / footer.tsx

... 생략 ...

	  @media screen AND ${({theme}) => theme.mediaQuery.mobile} {
    flex-direction: column;
    align-items: center;
  }
`;

export default Footer;
// components / banner / BannerItem.tsx

... 생략 ...

@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
    .content {
      width: 100%;
      background: linear-gradient(to top, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));

      h2 {
        font-size: 1.5rem;
        margin-bottom: 8px;
      }
      p {
        font-size: 0.75rem;
      }
    }
  }
`;

export default BannerItem;
// components / banner / Banner.tsx

... 생략 ...

const BannerButtonStyle = styled.div`

	... 생략 ...
	
	@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
      width: 28px;
      height: 28px;
      font-size: 1.5rem;

      &.prev {
        left: 0;
      }
      &.next {
        right: 0;
      }
    }
  }
`;

const BannerIndicatorStyle = styled.div`

... 생략 ...
	
	@media screen AND ${({theme}) => theme.mediaQuery.mobile} {
    bottom: 0;
    span {
      width: 12px;
      height: 12px;

      &.active {
        width: 24px;
      }
    }
  }
`;

export default Banner;
// pages / Login.tsx

... 생략 ...

	<InputText placeholder='이메일' inputType='email' 
	{...register("email", { required: true })} inputMode='email'/>
	
	... 생략 ...
	
	<InputText placeholder='비밀번호' inputType='password' 
	{...register("password", { required: true })} inputMode='text'/>
profile
프론트엔드개발자가 되고 싶어서 열심히 땅굴 파는 자

0개의 댓글