diff --git a/README.md b/README.md index 3eb7ab679..ffdb4a67d 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,17 @@ ## 기능 목록 -- [ ] Domain +- [x] Domain - [x] `인기순`으로 영화 목록 불러온다. - 한 번에 20개씩 불러온다. - 별도로 받은 데이터를 정렬하지 않는다. - - [ ] 통신에 실패한 경우 오류를 발생시킨다. + - [x] 통신에 실패한 경우 오류를 발생시킨다. - [x] 인자로 받은 쿼리로 영화 목록을 불러온다. - 한 번에 20개씩 불러온다. - - [ ] 통신에 실패한 경우 오류를 발생시킨다. + - [x] 통신에 실패한 경우 오류를 발생시킨다. -- [ ] UI +- [x] UI - [x] 영화 목록의 1페이지(20개)를 출력한다. @@ -29,4 +29,4 @@ - [x] 단, 페이지 끝에 도달한 경우에는 더보기 버튼을 화면에 출력하지 않는다. - [x] 데이터를 로딩하는 동안 영화 목록 아이템에 대한 Skeleton UI를 출력한다. - - [ ] 오류가 발생한 경우, 오류 메세지를 띄워준다. + - [x] 오류가 발생한 경우, 오류 메세지를 띄워준다. diff --git a/src/components/App.ts b/src/components/App.ts index 4423b233f..d1072862b 100644 --- a/src/components/App.ts +++ b/src/components/App.ts @@ -67,24 +67,53 @@ class App { } private async renderPopularMovieList() { - const res = await getPopularMovieList({ page: this.currentPage }); - const movies = this.extractMovies(res.results); - setTimeout(() => { - this.movieMain.reRender(movies); - }, 500); + try { + const res = await getPopularMovieList({ page: this.currentPage }); + + if (this.currentPage === res.total_pages) { + this.movieMain.removeMovieMoreButton(); + } + + const movies = this.extractMovies(res.results); + setTimeout(() => { + this.movieMain.reRender(movies); + }, 500); + } catch (error) { + if (error instanceof Error) { + this.currentPage -= 1; + this.movieMain.renderMessage(error.message); + this.movieMain.reRender([]); + } + } } private async searchMovies(query: string) { - const res = await getMovieListByQuery({ page: this.currentPage, query }); - const movies = this.extractMovies(res.results); - - if (this.currentPage === res.total_pages) { - this.movieMain.removeMovieMoreButton(); + try { + const res = await getMovieListByQuery({ page: this.currentPage, query }); + const movies = this.extractMovies(res.results); + if (!movies.length) { + this.renderNoResult("검색 결과가 없습니다."); + } + + if (this.currentPage === res.total_pages) { + this.movieMain.removeMovieMoreButton(); + } + + setTimeout(() => { + this.movieMain.reRender(movies); + }, 500); + } catch (error) { + if (error instanceof Error) { + this.currentPage -= 1; + this.renderNoResult(error.message); + this.movieMain.reRender([]); + } } + } - setTimeout(() => { - this.movieMain.reRender(movies); - }, 500); + private renderNoResult(message: string) { + this.movieMain.removeMovieMoreButton(); + this.movieMain.renderMessage(message); } private extractMovies(movies: SearchMovieResult[] | PopularMovieResult[]) { diff --git a/src/components/MovieHeader/MovieSearchBox.ts b/src/components/MovieHeader/MovieSearchBox.ts index 8868b2b6a..922fd381e 100644 --- a/src/components/MovieHeader/MovieSearchBox.ts +++ b/src/components/MovieHeader/MovieSearchBox.ts @@ -25,9 +25,25 @@ class MovieSearchBox { private searchByQuery(e: Event) { e.preventDefault(); - if (e.target instanceof HTMLFormElement) { + if (!(e.target instanceof HTMLFormElement)) { + return; + } + + try { const query = e.target["query"].value; + this.validateQuery(query); this.props.searchBoxSubmitHandler(query); + } catch (error) { + if (error instanceof Error) alert(error.message); + } + } + + private validateQuery(query: string) { + if (query.trim().length === 0) { + throw new Error("검색어를 입력해주세요."); + } + if (query.length > 500) { + throw new Error("검색어는 500자 미만으로 입력해주세요."); } } } diff --git a/src/components/MovieMain/MovieListBox/MovieList/MovieList.ts b/src/components/MovieMain/MovieListBox/MovieList/MovieList.ts index 26397b6f9..b4fb47a64 100644 --- a/src/components/MovieMain/MovieListBox/MovieList/MovieList.ts +++ b/src/components/MovieMain/MovieListBox/MovieList/MovieList.ts @@ -3,11 +3,14 @@ import MovieItem, { Movie } from "./MovieItem"; import createElement from "../../../utils/createElement"; class MovieList { + private static MAX_ITEM_OF_PAGE = 20; $element; movieList; constructor() { - this.movieList = Array.from({ length: 20 }).map(() => new MovieItem()); + this.movieList = Array.from({ length: MovieList.MAX_ITEM_OF_PAGE }).map( + () => new MovieItem() + ); this.$element = this.generateMovieList(); } @@ -19,9 +22,8 @@ class MovieList { }); } - //TODO: 20개가 전부 오지 않았을 경우 요소 remove reRender(movies: Movie[]) { - Array.from({ length: 20 }).forEach((_, index) => { + Array.from({ length: MovieList.MAX_ITEM_OF_PAGE }).forEach((_, index) => { const movieItem = this.movieList[index]; if (index < movies.length) { @@ -35,9 +37,41 @@ class MovieList { } appendSkeleton() { - this.movieList = Array.from({ length: 20 }).map(() => new MovieItem()); + this.movieList = Array.from({ length: MovieList.MAX_ITEM_OF_PAGE }).map( + () => new MovieItem() + ); this.$element.append(...this.movieList.map((item) => item.$element)); } + + removeAllSkeleton() { + this.movieList.forEach((movieItem) => { + movieItem.$element.remove(); + }); + } + + renderMessage(message: string) { + this.removeAllSkeleton(); + + const textInvalidResult = createElement({ + tagName: "div", + attribute: { + class: "text-invalid-result", + }, + children: [message], + }); + + this.$element.append(textInvalidResult); + } + + removeMessage() { + const $lastChild = this.$element.lastChild; + if (!($lastChild instanceof HTMLElement)) { + return; + } + if ($lastChild.classList.contains("text-invalid-result")) { + $lastChild.remove(); + } + } } export default MovieList; diff --git a/src/components/MovieMain/MovieListBox/MovieListBox.ts b/src/components/MovieMain/MovieListBox/MovieListBox.ts index 04b41d1dc..b1d2bf4a9 100644 --- a/src/components/MovieMain/MovieListBox/MovieListBox.ts +++ b/src/components/MovieMain/MovieListBox/MovieListBox.ts @@ -23,6 +23,7 @@ class MovieListBox { this.movieList = new MovieList(); this.button = new MovieMoreButton({ onClickHandler: () => { + this.movieList.removeMessage(); this.showMoreMovies(); onMovieMoreButtonClick(); }, @@ -38,6 +39,10 @@ class MovieListBox { this.button.toggleDisabled(); } + renderMessage(message: string) { + this.movieList.renderMessage(message); + } + showMoreMovies() { this.button.toggleDisabled(); this.movieList.appendSkeleton(); diff --git a/src/components/MovieMain/MovieMain.ts b/src/components/MovieMain/MovieMain.ts index 772ffe8ff..d8a72f930 100644 --- a/src/components/MovieMain/MovieMain.ts +++ b/src/components/MovieMain/MovieMain.ts @@ -36,6 +36,10 @@ class MovieMain { this.movieListBox.removeMovieMoreButton(); } + renderMessage(message: string) { + this.movieListBox.renderMessage(message); + } + private replace(movieListBoxElement: HTMLElement) { this.$element.replaceChildren(movieListBoxElement); } diff --git a/src/domain/getMovieListByQuery.ts b/src/domain/getMovieListByQuery.ts index d261ebd87..37ddf629a 100644 --- a/src/domain/getMovieListByQuery.ts +++ b/src/domain/getMovieListByQuery.ts @@ -1,3 +1,5 @@ +import validateResponse from "./validateResponse"; + const getMovieListByQuery = async ({ page, query, @@ -10,6 +12,8 @@ const getMovieListByQuery = async ({ const moviesUrl = `${url}?${queryParams}`; const res = await fetch(moviesUrl); + validateResponse(res.status); + const movieList = await res.json(); return movieList; diff --git a/src/domain/getPopularMovieList.ts b/src/domain/getPopularMovieList.ts index 324ba0665..348b1bba7 100644 --- a/src/domain/getPopularMovieList.ts +++ b/src/domain/getPopularMovieList.ts @@ -1,15 +1,17 @@ +import validateResponse from "./validateResponse"; + const getPopularMovieList = async ({ page, }: { page: number; }): Promise => { - const options = { method: "GET", headers: { accept: "application/json" } }; - const url = "https://api.themoviedb.org/3/movie/popular"; const queryParams = `language=ko-KR&page=${page}&api_key=${process.env.API_KEY}`; const popularMoviesUrl = `${url}?${queryParams}`; const res = await fetch(popularMoviesUrl); + validateResponse(res.status); + const popularMovieList = await res.json(); return popularMovieList; diff --git a/src/domain/validateResponse.ts b/src/domain/validateResponse.ts new file mode 100644 index 000000000..d9c1e0a23 --- /dev/null +++ b/src/domain/validateResponse.ts @@ -0,0 +1,35 @@ +const validateResponse = (status: number) => { + if (status === 401) { + throw new Error("인증 정보가 올바르지 않습니다. API KEY를 확인해주세요."); + } + if (status === 503) { + throw new Error( + "죄송합니다. 현재 서버가 일시적으로 오프라인 상태입니다. 나중에 다시 시도해주세요." + ); + } + if (500 <= status) { + throw new Error( + "죄송합니다. 서버가 점검중입니다. 다음에 다시 시도해주세요." + ); + } + if (400 <= status) { + throw new Error("알 수 없는 오류가 발생했습니다. 다시 시도해주세요."); + } +}; + +export default validateResponse; + +// TMDB 에서 올 수 있는 모든 ERROR 목록 +// 400: "사용자 이름과 비밀번호를 제공해야 합니다. 잘못된 페이지, 날짜형식입니다. validation failed. 검증실패.", +// 401: "API Key가 없습니다. API Key가 일시 정지됐습니다.", +// 403: "이 사용자는 정지되었습니다. 제출하려는 데이터가 이미 존재합니다.", +// 404: "잘못된 아이디거나 찾을 수 없습니다. ", +// 405: "이 서비스는 그 형식이 아닙니다. 잘못된 형식. 해당 형식이 존재하지 않습니다.", +// 406: "잘못 승인된 헤더입니다.", +// 422: "잘못된 날짜 범위(14일 이하여야 합니다). invalid parameter. 요청의 파라미터가 올바르지 않습니다.", +// 429: "요청 횟수가 허용 한도(40회)를 초과했습니다.", +// 500: "default server error. 내부 오류. 문제 발생. TMDB에 문의해주세요.", +// 501: "서버가 존재하지 않습니다.", +// 502: "백엔드 서버에 연결할 수 없습니다.", +// 503: "이 API가 점검 중입니다. 이 서비스는 일시적으로 오프라인 상태입니다. 나중에 다시 시도하세요.", +// 504: "백엔드 서버에 대한 요청 시간 초과. 다시 시도하세요.", diff --git a/templates/common.css b/templates/common.css index 4808f027f..1236d091c 100644 --- a/templates/common.css +++ b/templates/common.css @@ -167,3 +167,12 @@ header .search-box > .search-button { background: url("./search_button.png") transparent no-repeat 0 1px; background-size: contain; } + +.text-invalid-result { + color: #aaa; + font-size: 1.5rem; + grid-column-start: 1; + grid-column-end: 5; + text-align: center; + margin-top: 2rem; +}