diff --git a/src/api/bookmark/fetchBookmarkList.ts b/src/api/bookmark/fetchBookmarkList.ts new file mode 100644 index 00000000..148c1d14 --- /dev/null +++ b/src/api/bookmark/fetchBookmarkList.ts @@ -0,0 +1,29 @@ +import { BookmarkStore } from "types/common/bookmarkTypes"; + +import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; + +import axiosInstance from "api/axiosInstance"; + +const fetchBookmarkList = async () => { + const accessToken = window.sessionStorage.getItem(ACCESS_TOKEN); + + if (!accessToken) { + window.sessionStorage.removeItem(ACCESS_TOKEN); + window.alert("다시 로그인 해주세요"); + window.location.href = "/"; + return; + } + + const { data } = await axiosInstance.get( + ENDPOINTS.BOOKMARKS, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + return data; +}; + +export default fetchBookmarkList; diff --git a/src/api/mypage/fetchUserProfile.ts b/src/api/mypage/fetchUserProfile.ts new file mode 100644 index 00000000..8a85fe2b --- /dev/null +++ b/src/api/mypage/fetchUserProfile.ts @@ -0,0 +1,29 @@ +import type { UserProfileInformation } from "types/common"; + +import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; + +import axiosInstance from "api/axiosInstance"; + +const fetchUserProfile = async () => { + const accessToken = window.sessionStorage.getItem(ACCESS_TOKEN); + + if (!accessToken) { + window.sessionStorage.removeItem(ACCESS_TOKEN); + window.alert("다시 로그인 해주세요"); + window.location.href = "/"; + return; + } + + const { data } = await axiosInstance.get( + ENDPOINTS.USER_PROFILE, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + return data; +}; + +export default fetchUserProfile; diff --git a/src/api/mypage/fetchUserReviewList.ts b/src/api/mypage/fetchUserReviewList.ts new file mode 100644 index 00000000..ba04f98f --- /dev/null +++ b/src/api/mypage/fetchUserReviewList.ts @@ -0,0 +1,36 @@ +import { FetchParamProps } from "types/apiTypes"; +import type { UserReview } from "types/common"; + +import { ACCESS_TOKEN, ENDPOINTS, SIZE } from "constants/api"; + +import axiosInstance from "api/axiosInstance"; + +interface UserReviewResponse { + hasNext: boolean; + reviews: UserReview[]; +} + +const fetchUserReviewList = async ({ pageParam = 0 }: FetchParamProps) => { + const accessToken = window.sessionStorage.getItem(ACCESS_TOKEN); + + if (!accessToken) { + window.sessionStorage.removeItem(ACCESS_TOKEN); + window.alert("다시 로그인 해주세요"); + window.location.href = "/"; + throw new Error("엑세스토큰이 유효하지 않습니다"); + } + + const { data } = await axiosInstance.get( + ENDPOINTS.USER_REVIEWS, + { + params: { page: pageParam, size: SIZE.REVIEW }, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + return { ...data, nextPageParam: pageParam + 1 }; +}; + +export default fetchUserReviewList; diff --git a/src/asset/index.ts b/src/asset/index.ts index d7f159d8..087683f2 100644 --- a/src/asset/index.ts +++ b/src/asset/index.ts @@ -2,3 +2,5 @@ export { ReactComponent as CloseIcon } from "./close-icon.svg"; export { ReactComponent as LogoLight } from "./logo-light.svg"; export { ReactComponent as PlusIcon } from "./plus-icon.svg"; export { ReactComponent as SearchIcon } from "./search-icon.svg"; +export { ReactComponent as RightIcon } from "./right-icon.svg"; +export { ReactComponent as LeftIcon } from "./left-icon.svg"; diff --git a/src/asset/left-icon.svg b/src/asset/left-icon.svg new file mode 100644 index 00000000..0ffab95a --- /dev/null +++ b/src/asset/left-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/asset/right-icon.svg b/src/asset/right-icon.svg new file mode 100644 index 00000000..b57e9615 --- /dev/null +++ b/src/asset/right-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/common/StoreList/StoreList.tsx b/src/components/common/StoreList/StoreList.tsx index e5a9a90e..303ebe4e 100644 --- a/src/components/common/StoreList/StoreList.tsx +++ b/src/components/common/StoreList/StoreList.tsx @@ -1,12 +1,12 @@ import Divider from "../Divider/Divider"; import { Fragment } from "react"; -import { Store } from "types/common"; +import type { BookmarkStore, Store } from "types/common"; import * as S from "components/common/StoreList/StoreList.style"; import StoreListItem from "components/common/StoreListItem/StoreListItem"; interface StoreListProps { - stores?: Store[]; + stores?: Store[] | BookmarkStore[]; } function StoreList({ stores }: StoreListProps) { diff --git a/src/components/layout/MenuDrawer/MenuDrawer.tsx b/src/components/layout/MenuDrawer/MenuDrawer.tsx index 9310bcc1..1f47bce2 100644 --- a/src/components/layout/MenuDrawer/MenuDrawer.tsx +++ b/src/components/layout/MenuDrawer/MenuDrawer.tsx @@ -1,6 +1,7 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { useContext, useEffect } from "react"; import ReactDOM from "react-dom"; +import { useNavigate } from "react-router-dom"; import { Campus } from "types/campus"; import { AUTH_LINK } from "constants/api"; @@ -26,6 +27,7 @@ function MenuDrawer({ closeMenu, isLoggedIn }: MenuDrawerProps) { const campus = useContext(campusContext); const otherCampus = getOtherCampus(campus as Campus); const setCampus = useContext(setCampusContext); + const navigate = useNavigate(); const { logout } = useLogin(); @@ -49,6 +51,7 @@ function MenuDrawer({ closeMenu, isLoggedIn }: MenuDrawerProps) { logout(); closeMenu(); window.alert(MESSAGES.LOGOUT_COMPLETE); + navigate(PATHNAME.HOME); }; useEffect(() => { @@ -74,6 +77,7 @@ function MenuDrawer({ closeMenu, isLoggedIn }: MenuDrawerProps) { + 마이페이지 ) : ( <> diff --git a/src/components/pages/MyPage/BookmarkListPage/BookmarkListPage.style.ts b/src/components/pages/MyPage/BookmarkListPage/BookmarkListPage.style.ts new file mode 100644 index 00000000..78fb7579 --- /dev/null +++ b/src/components/pages/MyPage/BookmarkListPage/BookmarkListPage.style.ts @@ -0,0 +1,18 @@ +import styled, { css } from "styled-components"; + +export const Container = styled.div` + position: relative; + padding: ${({ theme }) => theme.spacer.spacing3}; +`; + +export const HeaderWrapper = styled.header` + display: flex; + justify-content: space-between; + background-color: white; +`; + +export const headerStyle = css` + font-weight: bold; + padding-bottom: ${({ theme }) => theme.spacer.spacing3}; + margin-bottom: ${({ theme }) => theme.spacer.spacing4}; +`; diff --git a/src/components/pages/MyPage/BookmarkListPage/BookmarkListPage.tsx b/src/components/pages/MyPage/BookmarkListPage/BookmarkListPage.tsx new file mode 100644 index 00000000..f36642fc --- /dev/null +++ b/src/components/pages/MyPage/BookmarkListPage/BookmarkListPage.tsx @@ -0,0 +1,51 @@ +import * as S from "./BookmarkListPage.style"; +import { useQuery } from "react-query"; +import { useNavigate } from "react-router-dom"; + +import { NETWORK } from "constants/api"; + +import { LeftIcon } from "asset"; + +import fetchBookmarkList from "api/bookmark/fetchBookmarkList"; + +import ErrorImage from "components/common/ErrorImage/ErrorImage"; +import ErrorText from "components/common/ErrorText/ErrorText"; +import Spinner from "components/common/Spinner/Spinner"; +import StoreList from "components/common/StoreList/StoreList"; +import Text from "components/common/Text/Text"; + +function BookmarkListPage() { + const navigate = useNavigate(); + + const { data, isLoading, isFetching, isError, error } = useQuery( + "bookmarkStore", + () => fetchBookmarkList(), + { + retry: NETWORK.RETRY_COUNT, + refetchOnWindowFocus: false, + } + ); + + const bookmarkedStoreData = data ?? []; + + return ( + + + navigate(-1)} /> + 나의 맛집 + 지도 + + {(isLoading || isFetching) && } + {isError && error instanceof Error && ( + + )} + {bookmarkedStoreData.length > 0 ? ( + + ) : ( + 가게 정보가 없습니다. + )} + + ); +} + +export default BookmarkListPage; diff --git a/src/components/pages/MyPage/MyPage.style.ts b/src/components/pages/MyPage/MyPage.style.ts new file mode 100644 index 00000000..8db7f2c1 --- /dev/null +++ b/src/components/pages/MyPage/MyPage.style.ts @@ -0,0 +1,47 @@ +import { Link } from "react-router-dom"; + +import styled from "styled-components"; + +export const Container = styled.section` + position: relative; + padding: ${({ theme }) => theme.spacer.spacing3}; + + & > section:not(:first-child) { + margin-top: ${({ theme }) => theme.spacer.spacing5}; + } +`; + +export const SectionHeaderWrapper = styled.div` + margin-bottom: ${({ theme }) => theme.spacer.spacing4}; + + display: flex; + justify-content: space-between; + align-items: center; + + & > header { + margin-bottom: 0; + } +`; + +export const ShowAllLink = styled(Link)` + display: flex; + gap: ${({ theme }) => theme.spacer.spacing1}; + align-items: center; + + & > p { + color: ${({ theme }) => theme.color.gray600}; + } +`; + +export const EmptyList = styled.div` + padding: ${({ theme }) => theme.spacer.spacing7} 0; + color: ${({ theme }) => theme.color.gray600}; + text-align: center; +`; + +export const ReviewItemWrapper = styled.div` + margin-bottom: ${({ theme }) => theme.spacer.spacing3}; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacer.spacing3}; +`; diff --git a/src/components/pages/MyPage/MyPage.tsx b/src/components/pages/MyPage/MyPage.tsx new file mode 100644 index 00000000..907dc961 --- /dev/null +++ b/src/components/pages/MyPage/MyPage.tsx @@ -0,0 +1,114 @@ +import * as S from "./MyPage.style"; +import MyReviewItem from "./MyReviewItem/MyReviewItem"; +import UserProfile from "./UserProfile/UserProfile"; +import { MdArrowBackIos } from "react-icons/md"; +import { useQuery } from "react-query"; +import { useNavigate } from "react-router-dom"; + +import { NETWORK, SIZE } from "constants/api"; +import { PATHNAME } from "constants/routes"; + +import { RightIcon } from "asset"; + +import fetchBookmarkList from "api/bookmark/fetchBookmarkList"; +import fetchUserProfile from "api/mypage/fetchUserProfile"; +import fetchUserReviewList from "api/mypage/fetchUserReviewList"; + +import Divider from "components/common/Divider/Divider"; +import ErrorImage from "components/common/ErrorImage/ErrorImage"; +import SectionHeader from "components/common/SectionHeader/SectionHeader"; +import Spinner from "components/common/Spinner/Spinner"; +import StoreList from "components/common/StoreList/StoreList"; +import Text from "components/common/Text/Text"; + +function MyPage() { + const navigate = useNavigate(); + + const { + data: profileData, + isLoading, + isError, + error, + } = useQuery("userProfile", () => fetchUserProfile(), { + retry: NETWORK.RETRY_COUNT, + refetchOnWindowFocus: false, + }); + + const { data: bookmarkedStoreData = [] } = useQuery( + "bookmarkedStore", + () => fetchBookmarkList(), + { + retry: NETWORK.RETRY_COUNT, + refetchOnWindowFocus: false, + } + ); + + const { data: myReviewData } = useQuery("myReview", fetchUserReviewList, { + retry: NETWORK.RETRY_COUNT, + refetchOnWindowFocus: false, + }); + + const myReviews = myReviewData?.reviews ?? []; + + if (!profileData) return null; + + return ( + + } + onClick={() => { + navigate(-1); + }} + > + 마이페이지 + +
+ {isLoading && } + {isError && error instanceof Error && ( + + )} + +
+ +
+ + 나의 맛집 + + 전체보기 + + + + {bookmarkedStoreData.length > 0 ? ( + + ) : ( + + 저장된 맛집이 없습니다 + + )} +
+
+ + 나의 리뷰 + + 전체보기 + + + + {myReviews.length > 0 ? ( + myReviews.slice(0, SIZE.MY_PAGE_ITEM).map((review) => ( + + + + + )) + ) : ( + + 작성한 리뷰가 없습니다 + + )} +
+
+ ); +} + +export default MyPage; diff --git a/src/components/pages/MyPage/MyReviewItem/MyReviewItem.style.ts b/src/components/pages/MyPage/MyReviewItem/MyReviewItem.style.ts new file mode 100644 index 00000000..6123f78c --- /dev/null +++ b/src/components/pages/MyPage/MyReviewItem/MyReviewItem.style.ts @@ -0,0 +1,101 @@ +import styled, { css } from "styled-components"; + +import Image from "components/common/Image/Image"; + +export const StoreReviewContainer = styled.li` + display: flex; + gap: ${({ theme }) => theme.spacer.spacing4}; + + width: 100%; + height: fit-content; + + border: none; + border-radius: 0.25rem; +`; + +export const StoreImage = styled(Image)` + width: 5.6rem; + height: 5.6rem; + border-radius: 50%; + object-fit: cover; + object-position: center; +`; + +export const ReviewContentWrapper = styled.div` + width: 100%; + + display: flex; + flex-direction: column; +`; + +export const Header = styled.header` + position: relative; + width: 100%; + margin-bottom: ${({ theme }) => theme.spacer.spacing2}; + + display: flex; + justify-content: space-between; + cursor: pointer; +`; + +export const UserReviewInfoWrapper = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacer.spacing1}; +`; + +export const ReviewBottom = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacer.spacing3}; +`; + +export const RatingWrapper = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacer.spacing1}; +`; + +export const DropBoxButtonList = styled.ul` + display: flex; + flex-direction: column; + row-gap: 0.5rem; + padding: 12px 8px; + border-radius: ${({ theme }) => theme.borderRadius.small}; + + background-color: ${({ theme }) => theme.color.white}; +`; + +export const DropBoxButton = styled.button` + padding: 10px; + + background-color: transparent; + border: none; + white-space: nowrap; + + &:hover { + font-weight: 700; + } +`; + +export const titleTextStyle = css` + font-weight: 600; +`; + +export const subTextStyle = css` + color: ${({ theme }) => theme.color.gray600}; + font-weight: 600; +`; + +export const subTextNumberStyle = css` + color: ${({ theme }) => theme.color.gray600}; +`; + +export const bodyTextStyle = css` + max-height: 16rem; + overflow: hidden; + word-break: break-all; +`; + +export const menuTextStyle = css` + color: ${({ theme }) => theme.color.gray600}; + align-self: flex-end; +`; diff --git a/src/components/pages/MyPage/MyReviewItem/MyReviewItem.tsx b/src/components/pages/MyPage/MyReviewItem/MyReviewItem.tsx new file mode 100644 index 00000000..9b5d7294 --- /dev/null +++ b/src/components/pages/MyPage/MyReviewItem/MyReviewItem.tsx @@ -0,0 +1,159 @@ +import * as S from "./MyReviewItem.style"; +import { AxiosError } from "axios"; +import { MouseEvent, useState } from "react"; +import { useMutation, useQueryClient } from "react-query"; +import { useNavigate } from "react-router-dom"; +import { UserReview } from "types/common"; +import repeatComponent from "util/repeatComponent"; + +import { PATHNAME } from "constants/routes"; + +import deleteReviewItem from "api/review/deleteReviewItem"; + +import Divider from "components/common/Divider/Divider"; +import DropDownBox from "components/common/DropDownBox/DropDownBox"; +import MeatballButton from "components/common/MeatballButton/MeatballButton"; +import Star from "components/common/Star/Star"; +import Text from "components/common/Text/Text"; + +import ReviewUpdateBottomSheet from "components/pages/StoreDetailPage/ReviewUpdateBottomSheet/ReviewUpdateBottomSheet"; + +function MyReviewItem({ + id, + restaurant, + updatable, + content, + rating, + menu, +}: UserReview) { + const navigate = useNavigate(); + + const deleteMutation = useMutation(() => + deleteReviewItem({ + restaurantId: String(restaurant.id), + articleId: String(id), + }) + ); + + const [isDropBoxOpen, setIsDropBoxOpen] = useState(false); + const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); + + const queryClient = useQueryClient(); + + const handleMeatballButtonClick = (event: MouseEvent) => { + event.stopPropagation(); + setIsDropBoxOpen((prev) => !prev); + }; + + const handleDropBoxClose = () => setIsDropBoxOpen(false); + + const handleReviewUpdateClick = (event: MouseEvent) => { + event.stopPropagation(); + setIsBottomSheetOpen(true); + handleDropBoxClose(); + }; + + const handleReviewDeleteClick = (event: MouseEvent) => { + event.stopPropagation(); + if (window.confirm("정말 삭제하시겠습니까?")) { + deleteMutation.mutate({ + restaurantId: restaurant.id, + id, + }); + } + }; + + const handleReviewModalClick = () => { + queryClient.invalidateQueries([ + "reviewDetailStore", + { restaurantId: restaurant.id }, + ]); + }; + const reviewInfo = { + id: String(id), + restaurantId: String(restaurant.id), + rating, + content, + menu, + }; + + return ( + <> + + + + { + navigate(`${PATHNAME.STORE_DETAIL}/${restaurant.id}`); + }} + > + {restaurant.name} + {updatable && ( + <> +
+ +
+ {isDropBoxOpen && ( + + +
  • + + 수정 + +
  • +
  • + +
  • +
  • + + 삭제 + +
  • +
    +
    + )} + + )} +
    + + + {repeatComponent(, rating)} + {repeatComponent(, 5 - rating)} + + + {content} + + + {menu} + + +
    +
    + {isBottomSheetOpen && ( + setIsBottomSheetOpen(false)} + defaultReviewItem={reviewInfo} + onSuccess={handleReviewModalClick} + /> + )} + + ); +} + +export default MyReviewItem; diff --git a/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.style.ts b/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.style.ts new file mode 100644 index 00000000..6111fbf4 --- /dev/null +++ b/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.style.ts @@ -0,0 +1,25 @@ +import styled, { css } from "styled-components"; + +export const Container = styled.div` + position: relative; + padding: ${({ theme }) => theme.spacer.spacing3}; +`; + +export const HeaderWrapper = styled.header` + display: flex; + justify-content: space-between; + background-color: white; +`; + +export const ReviewItemWrapper = styled.div` + margin-bottom: ${({ theme }) => theme.spacer.spacing3}; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacer.spacing3}; +`; + +export const headerStyle = css` + font-weight: bold; + padding-bottom: ${({ theme }) => theme.spacer.spacing3}; + margin-bottom: ${({ theme }) => theme.spacer.spacing4}; +`; diff --git a/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.tsx b/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.tsx new file mode 100644 index 00000000..af616898 --- /dev/null +++ b/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.tsx @@ -0,0 +1,67 @@ +import MyReviewItem from "../MyReviewItem/MyReviewItem"; +import * as S from "./MyReviewListPage.style"; +import { useInfiniteQuery } from "react-query"; +import { useNavigate } from "react-router-dom"; +import { UserReview } from "types/common"; + +import { LeftIcon } from "asset"; + +import getNextPageParam from "api/getNextPageParam"; +import fetchUserReviewList from "api/mypage/fetchUserReviewList"; + +import Divider from "components/common/Divider/Divider"; +import ErrorImage from "components/common/ErrorImage/ErrorImage"; +import ErrorText from "components/common/ErrorText/ErrorText"; +import InfiniteScroll from "components/common/InfiniteScroll/InfiniteScroll"; +import Spinner from "components/common/Spinner/Spinner"; +import Text from "components/common/Text/Text"; + +function MyReviewListPage() { + const navigate = useNavigate(); + + const { data, error, isLoading, isError, fetchNextPage, isFetching } = + useInfiniteQuery(["myReviewList"], fetchUserReviewList, { + getNextPageParam, + }); + + const loadMoreReviews = () => { + fetchNextPage(); + }; + + const reviews = + data?.pages.reduce( + (prevReviews, { reviews: currentReviews }) => [ + ...prevReviews, + ...currentReviews, + ], + [] + ) || []; + + return ( + + + navigate(-1)} /> + 나의 리뷰 +
    +
    + + {(isLoading || isFetching) && } + {isError && error instanceof Error && ( + + )} + {reviews.length ? ( + reviews.map((review) => ( + + + + + )) + ) : ( + 작성된 리뷰가 없습니다. + )} + +
    + ); +} + +export default MyReviewListPage; diff --git a/src/components/pages/MyPage/UserProfile/UserProfile.style.ts b/src/components/pages/MyPage/UserProfile/UserProfile.style.ts new file mode 100644 index 00000000..a68d7628 --- /dev/null +++ b/src/components/pages/MyPage/UserProfile/UserProfile.style.ts @@ -0,0 +1,40 @@ +import styled, { css } from "styled-components"; + +export const Container = styled.div` + margin-bottom: ${({ theme }) => theme.spacer.spacing5}; + display: flex; + gap: ${({ theme }) => theme.spacer.spacing4}; +`; + +export const ProfileImage = styled.img` + width: 7.2rem; + height: 7.2rem; + object-fit: cover; + border-radius: 50%; +`; + +export const ContentWrapper = styled.div` + width: 100%; + + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacer.spacing1}; +`; + +export const ReviewInfoWrapper = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacer.spacing1}; +`; + +export const usernameTextStyle = css` + font-weight: bold; +`; + +export const subTextStyle = css` + color: ${({ theme }) => theme.color.gray600}; + font-weight: bold; +`; + +export const subTextNumberStyle = css` + color: ${({ theme }) => theme.color.gray600}; +`; diff --git a/src/components/pages/MyPage/UserProfile/UserProfile.tsx b/src/components/pages/MyPage/UserProfile/UserProfile.tsx new file mode 100644 index 00000000..a7b68420 --- /dev/null +++ b/src/components/pages/MyPage/UserProfile/UserProfile.tsx @@ -0,0 +1,33 @@ +import * as S from "./UserProfile.style"; +import type { UserProfileInformation } from "types/common"; + +import Text from "components/common/Text/Text"; + +type UserProfileProps = UserProfileInformation; + +function UserProfile({ ...information }: UserProfileProps) { + return ( + + + + {information.username} + + + 후기 + + + {information.reviewCount} + + + 별점평균 + + + {information.averageRating} + + + + + ); +} + +export default UserProfile; diff --git a/src/components/pages/index.tsx b/src/components/pages/index.tsx index 3c14c7ab..b9ddf26f 100644 --- a/src/components/pages/index.tsx +++ b/src/components/pages/index.tsx @@ -5,3 +5,6 @@ export { default as Login } from "components/pages/Login/Login"; export { default as SearchResultPage } from "components/pages/SearchResultPage/SearchResultPage"; export { default as StoreDetailPage } from "components/pages/StoreDetailPage/StoreDetailPage"; export { default as StoreDemandPage } from "components/pages/StoreDemandPage/StoreDemandPage"; +export { default as MyPage } from "components/pages/MyPage/MyPage"; +export { default as BookmarkListPage } from "components/pages/MyPage/BookmarkListPage/BookmarkListPage"; +export { default as MyReviewListPage } from "components/pages/MyPage/MyReviewListPage/MyReviewListPage"; diff --git a/src/constants/api.tsx b/src/constants/api.tsx index b3dacda6..ab5cc435 100644 --- a/src/constants/api.tsx +++ b/src/constants/api.tsx @@ -16,6 +16,9 @@ export const ENDPOINTS = { `/restaurants/${restaurantId}/reviews/${articleId}`, STORE_REQUESTS: (campusId: CampusId) => `/campuses/${campusId}/restaurantDemands`, + USER_PROFILE: "/mypage/profile", + USER_REVIEWS: "/mypage/reviews", + BOOKMARKS: "/restaurants/bookmarks", BOOKMARK_STORE: (restaurantId: number) => `restaurants/${restaurantId}/bookmarks`, } as const; @@ -28,6 +31,7 @@ export const SIZE = { REVIEW: 5, LIST_ITEM: 10, RANDOM_ITEM: 5, + MY_PAGE_ITEM: 3, } as const; export const FILTERS = [ diff --git a/src/constants/routes.tsx b/src/constants/routes.tsx index d8b1ec46..4c61d85b 100644 --- a/src/constants/routes.tsx +++ b/src/constants/routes.tsx @@ -6,6 +6,9 @@ import { SearchResultPage, StoreDetailPage, StoreDemandPage, + MyPage, + BookmarkListPage, + MyReviewListPage, } from "components/pages"; export const PATHNAME = { @@ -15,6 +18,9 @@ export const PATHNAME = { STORE_DETAIL: "/store-detail", SEARCH: "/search", STORE_DEMAND: "store-demand", + MY_PAGE: "/my-page", + BOOKMARK_LIST_PAGE: "/my-page/bookmark", + MY_REVIEWS: "/my-page/reviews", WILD_CARD: "*", } as const; @@ -33,6 +39,18 @@ const MAIN_ROUTES = { path: PATHNAME.STORE_DEMAND, element: , }, + MY_PAGE: { + path: PATHNAME.MY_PAGE, + element: , + }, + BOOKMARK_LIST_PAGE: { + path: PATHNAME.BOOKMARK_LIST_PAGE, + element: , + }, + MY_REVIEWS: { + path: PATHNAME.MY_REVIEWS, + element: , + }, } as const; const SPECIAL_ROUTES = { diff --git a/src/mock/browser.ts b/src/mock/browser.ts index 9985cb76..0cffd8d9 100644 --- a/src/mock/browser.ts +++ b/src/mock/browser.ts @@ -1,6 +1,8 @@ import { setupWorker } from "msw"; import { + bookmarkHandler, + mypageHandler, userHandler, categoryHandler, restaurantHandler, @@ -10,6 +12,8 @@ import { } from "mock/handlers"; export const worker = setupWorker( + ...bookmarkHandler, + ...mypageHandler, ...userHandler, ...categoryHandler, ...restaurantHandler, diff --git a/src/mock/handlers/bookmarkHandler.ts b/src/mock/handlers/bookmarkHandler.ts index 6d64d8f0..96c39810 100644 --- a/src/mock/handlers/bookmarkHandler.ts +++ b/src/mock/handlers/bookmarkHandler.ts @@ -2,9 +2,14 @@ import { rest } from "msw"; import { API_BASE_URL } from "constants/api"; +import { bookmarkedStores } from "mock/userData"; + const bookmark: number[] = []; export const bookmarkHandler = [ + rest.get(`${API_BASE_URL}/restaurants/bookmarks`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(bookmarkedStores)); + }), rest.post( `${API_BASE_URL}/restaurants/:restaurantId/bookmarks`, (req, res, ctx) => { diff --git a/src/mock/handlers/index.ts b/src/mock/handlers/index.ts index f31b5043..2b77ee82 100644 --- a/src/mock/handlers/index.ts +++ b/src/mock/handlers/index.ts @@ -1,11 +1,14 @@ import { bookmarkHandler } from "mock/handlers/bookmarkHandler"; import { categoryHandler } from "mock/handlers/categoryHandler"; +import { mypageHandler } from "mock/handlers/mypageHandler"; import { restaurantHandler } from "mock/handlers/restaurantHandler"; import { reviewHandler } from "mock/handlers/reviewHandler"; import { storeDemandHandler } from "mock/handlers/storeDemandHandler"; import { userHandler } from "mock/handlers/userHandler"; export { + bookmarkHandler, + mypageHandler, userHandler, categoryHandler, restaurantHandler, diff --git a/src/mock/handlers/mypageHandler.ts b/src/mock/handlers/mypageHandler.ts new file mode 100644 index 00000000..ca71b974 --- /dev/null +++ b/src/mock/handlers/mypageHandler.ts @@ -0,0 +1,33 @@ +import { rest } from "msw"; + +import { API_BASE_URL } from "constants/api"; + +import { userProfile, userReviews } from "mock/userData"; + +export const mypageHandler = [ + rest.get(`${API_BASE_URL}/mypage/profile`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(userProfile)); + }), + + rest.get(`${API_BASE_URL}/mypage/reviews`, (req, res, ctx) => { + const page = req.url.searchParams.get("page"); + const size = req.url.searchParams.get("size"); + + if (!page || !size) { + return res(ctx.status(400), ctx.json({ message: "잘못된 요청입니다." })); + } + + const sizeNo = Number(size); + const pageNo = Number(page); + const startIndex = pageNo * sizeNo; + const endIndex = startIndex + sizeNo; + + return res( + ctx.status(200), + ctx.json({ + hasNext: endIndex < userReviews.length, + reviews: userReviews.slice(startIndex, endIndex), + }) + ); + }), +]; diff --git a/src/mock/userData.ts b/src/mock/userData.ts new file mode 100644 index 00000000..7250e5fe --- /dev/null +++ b/src/mock/userData.ts @@ -0,0 +1,169 @@ +import type { UserReview } from "types/common"; + +export const userProfile = { + username: "huni", + profileImage: + "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1470&q=80", + reviewCount: 4, + averageRating: 3.0, +}; + +export const bookmarkedStores = [ + { + id: 1, + name: "냠냠 치킨", + address: "서울 강남구 선릉로86길 5-4 1층", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + + distance: 0.5, + rating: 3, + reviewCount: 34, + saved: true, + }, + { + id: 2, + name: "얌얌 치킨", + address: "서울 강남구 역삼로65길 31", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + + distance: 0.5, + rating: 3, + reviewCount: 12, + saved: true, + }, + { + id: 3, + name: "념념 치킨", + address: "서울 강남구 선릉로86길 30 1층", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + + distance: 0.5, + rating: 3, + reviewCount: 5, + saved: true, + }, + { + id: 4, + name: "욤욤 치킨", + address: "서울 강남구 선릉로86길 5-4 1층", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + + distance: 0.5, + rating: 3, + reviewCount: 3, + saved: true, + }, +]; + +export const userReviews: UserReview[] = [ + { + id: 1, + restaurant: { + id: 2, + name: "맛있는 식당", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + }, + content: "맛있어요", + rating: 4, + menu: "맛있는 메뉴", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + + updatable: true, + }, + { + id: 2, + restaurant: { + id: 3, + name: "올로ㅗㄹ 식당", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + }, + content: "더 맛있어요", + rating: 5, + menu: "더 맛있는 메뉴", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + updatable: true, + }, + { + id: 3, + restaurant: { + id: 3, + name: "우에에ㅔㄱ 식당", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + }, + content: "더 맛있어요", + rating: 5, + menu: "더 맛있는 메뉴", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + updatable: true, + }, + { + id: 4, + restaurant: { + id: 3, + name: "냐아앙ㅁ 식당", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + }, + content: "더 맛있어요", + rating: 5, + menu: "더 맛있는 메뉴", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + updatable: true, + }, + { + id: 5, + restaurant: { + id: 3, + name: "뇨오옴 식당", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + }, + content: "더 맛있어요", + rating: 5, + menu: "더 맛있는 메뉴", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + updatable: true, + }, + { + id: 6, + restaurant: { + id: 3, + name: "룰랄룰 식당", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + }, + content: "더 맛있어요", + rating: 5, + menu: "더 맛있는 메뉴", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + updatable: true, + }, + { + id: 7, + restaurant: { + id: 3, + name: "크아악 식당", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + }, + content: "더 맛있어요", + rating: 5, + menu: "더 맛있는 메뉴", + imageUrl: + "https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + updatable: true, + }, +]; diff --git a/src/types/common/bookmarkTypes.ts b/src/types/common/bookmarkTypes.ts new file mode 100644 index 00000000..260bd84a --- /dev/null +++ b/src/types/common/bookmarkTypes.ts @@ -0,0 +1,5 @@ +import { Store } from "./storeTypes"; + +export interface BookmarkStore extends Omit { + saved: boolean; +} diff --git a/src/types/common/index.ts b/src/types/common/index.ts index 5fb520bf..39cffbb2 100644 --- a/src/types/common/index.ts +++ b/src/types/common/index.ts @@ -3,3 +3,5 @@ export * from "./campusTypes"; export * from "./categoryTypes"; export * from "./reviewTypes"; export * from "./storeTypes"; +export * from "./bookmarkTypes"; +export * from "./mypageTypes"; diff --git a/src/types/common/mypageTypes.ts b/src/types/common/mypageTypes.ts new file mode 100644 index 00000000..dcf57507 --- /dev/null +++ b/src/types/common/mypageTypes.ts @@ -0,0 +1,16 @@ +import type { ReviewInputShape } from "./reviewTypes"; +import type { Store } from "./storeTypes"; + +export interface UserProfileInformation { + username: string; + profileImage: string; + reviewCount: number; + averageRating: number; +} + +export interface UserReview extends ReviewInputShape { + id: number; + restaurant: Pick; + imageUrl: string | null; + updatable: boolean; +}