diff --git a/src/App.tsx b/src/App.tsx index 57ce9cf2..f421c99a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import Notice from 'pages/Notice'; import KakaoLogin from 'pages/Auth/OAuth/KakaoLogin'; import NaverLogin from 'pages/Auth/OAuth/NaverLogin'; import GoogleLogin from 'pages/Auth/OAuth/GoogleLogin'; +import MyPage from 'pages/MyPage'; import NotFoundPage from 'pages/Search/components/NotFoundPage'; import FollowProfile from 'pages/Follow/components/FollowProfile'; @@ -46,6 +47,7 @@ export default function App(): JSX.Element { } /> } /> + } /> } /> }> diff --git a/src/api/mypage/entity.ts b/src/api/mypage/entity.ts new file mode 100644 index 00000000..b515e71d --- /dev/null +++ b/src/api/mypage/entity.ts @@ -0,0 +1,57 @@ +import { User } from 'api/user/entity'; + +export interface Shop { + shopId: number, + placeId: string, + name: string, + category: string, +} + +export interface ReviewedShopsResponse { + content: Shop[], + totalPages: number, + totalElements: number +} + +export interface Review { + id: number, + content: string, + rate: number, + createdAt: string +} + +export interface ReviewsResponse { + content: Review[] +} + +export type Scrap = { + placeId: string, + category: string, + name: string, + photo: string, + ratingCount: number, + scrapId: number, + totalRating: number +}; +export interface ScrapResponse { + content: Scrap[], + totalPages: number, + totalElements: number +} + +export interface PatchProfileImageResponse { + profileImage: { + id: number, + originalName: string, + path: string, + url: string + } +} + +export interface PatchNicknameResponse { + nickname: string +} + +export interface FollowersResponse { + content: User[], +} diff --git a/src/api/mypage/index.ts b/src/api/mypage/index.ts new file mode 100644 index 00000000..e13b53b4 --- /dev/null +++ b/src/api/mypage/index.ts @@ -0,0 +1,29 @@ +import followApi from 'api/follow/followApiClient'; +import { + FollowersResponse, + PatchNicknameResponse, + PatchProfileImageResponse, ReviewedShopsResponse, ReviewsResponse, ScrapResponse, +} from './entity'; +import myPageApi from './mypageApiClient'; + +export const getReviewedShops = async () => myPageApi.get('/review/shops'); + +export const getReviews = async (placeId:string) => myPageApi.get(`/review/shop/${placeId}`); + +export const getScraps = async (pageParam:number) => myPageApi.get(`/scraps?cursor=${pageParam}`); + +export const patchProfileImage = async (image:FormData | null) => myPageApi.patch('/user/profile', image, { + headers: { + 'Content-Type': 'multipart/form-data', + }, +}); + +export const patchDefaultImage = async () => myPageApi.patch('/user/profile', { + headers: { + 'Content-Type': 'multipart/form-data', + }, +}); + +export const patchNickname = async (nickname:string) => myPageApi.patch('/user/me', { nickname }); + +export const getFollwers = async () => followApi.get('/follow/followers'); diff --git a/src/api/mypage/mypageApiClient.ts b/src/api/mypage/mypageApiClient.ts new file mode 100644 index 00000000..03b38f50 --- /dev/null +++ b/src/api/mypage/mypageApiClient.ts @@ -0,0 +1,18 @@ +import { API_PATH } from 'config/constants'; +import axios from 'axios'; + +const myPageApi = axios.create({ + baseURL: `${API_PATH}`, + timeout: 2000, +}); + +myPageApi.interceptors.request.use( + (config) => { + const accessToken = sessionStorage.getItem('accessToken'); + // eslint-disable-next-line no-param-reassign + if (config.headers && accessToken) config.headers.Authorization = `Bearer ${accessToken}`; + return config; + }, +); + +export default myPageApi; diff --git a/src/assets/svg/mypage/checkerboard-filled.svg b/src/assets/svg/mypage/checkerboard-filled.svg new file mode 100644 index 00000000..7955af4f --- /dev/null +++ b/src/assets/svg/mypage/checkerboard-filled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/svg/mypage/checkerboard.svg b/src/assets/svg/mypage/checkerboard.svg new file mode 100644 index 00000000..81b40b23 --- /dev/null +++ b/src/assets/svg/mypage/checkerboard.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/svg/mypage/close-arrow.svg b/src/assets/svg/mypage/close-arrow.svg new file mode 100644 index 00000000..e19c3802 --- /dev/null +++ b/src/assets/svg/mypage/close-arrow.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/svg/mypage/hamberger-filled.svg b/src/assets/svg/mypage/hamberger-filled.svg new file mode 100644 index 00000000..e4a6a86e --- /dev/null +++ b/src/assets/svg/mypage/hamberger-filled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/svg/mypage/hamberger.svg b/src/assets/svg/mypage/hamberger.svg new file mode 100644 index 00000000..57a5e40d --- /dev/null +++ b/src/assets/svg/mypage/hamberger.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/svg/mypage/not-exist.svg b/src/assets/svg/mypage/not-exist.svg new file mode 100644 index 00000000..7af5b39f --- /dev/null +++ b/src/assets/svg/mypage/not-exist.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/svg/mypage/open-arrow.svg b/src/assets/svg/mypage/open-arrow.svg new file mode 100644 index 00000000..c8d42ca3 --- /dev/null +++ b/src/assets/svg/mypage/open-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svg/mypage/option.svg b/src/assets/svg/mypage/option.svg new file mode 100644 index 00000000..ef292779 --- /dev/null +++ b/src/assets/svg/mypage/option.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svg/mypage/pencil.svg b/src/assets/svg/mypage/pencil.svg new file mode 100644 index 00000000..7193807a --- /dev/null +++ b/src/assets/svg/mypage/pencil.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svg/mypage/plus.svg b/src/assets/svg/mypage/plus.svg new file mode 100644 index 00000000..6506ea86 --- /dev/null +++ b/src/assets/svg/mypage/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svg/mypage/star-filled.svg b/src/assets/svg/mypage/star-filled.svg new file mode 100644 index 00000000..3849280b --- /dev/null +++ b/src/assets/svg/mypage/star-filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/toast/index.tsx b/src/components/toast/index.tsx index 8cbfa8f0..9a571cf7 100644 --- a/src/components/toast/index.tsx +++ b/src/components/toast/index.tsx @@ -1,6 +1,5 @@ import { ToastContainer } from 'react-toastify'; - -import 'react-toastify/dist/ReactToastify.min.css'; +import 'react-toastify/dist/ReactToastify.css'; export default function Toast() { return ( diff --git a/src/pages/MyPage/MyPage.module.scss b/src/pages/MyPage/MyPage.module.scss new file mode 100644 index 00000000..8aecd79a --- /dev/null +++ b/src/pages/MyPage/MyPage.module.scss @@ -0,0 +1,40 @@ +@use "src/utils/styles/mediaQuery" as media; + +.my-page { + width: 68%; + max-width: 575px; + margin: 0 auto; + padding-top: 50px; + + @include media.media-breakpoint-down(mobile) { + position: fixed; + top: 30px; + width: 100%; + margin-top: 0; + } +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgb(0 0 0 / 50%); + opacity: 1; +} + +.bottom-sheet { + width: 300px; + height: 500px; + margin-bottom: 30%; + padding: 24px 24px 35px; + border-radius: 45px; + + @include media.media-breakpoint-down(mobile) { + margin: 0; + padding: 24px 24px 100px; + bottom: -100px; + width: 100%; + } +} diff --git a/src/pages/MyPage/components/BoardSelector/BoardSelector.module.scss b/src/pages/MyPage/components/BoardSelector/BoardSelector.module.scss new file mode 100644 index 00000000..1303d362 --- /dev/null +++ b/src/pages/MyPage/components/BoardSelector/BoardSelector.module.scss @@ -0,0 +1,40 @@ +@use "src/utils/styles/mediaQuery" as media; + +.selector { + display: flex; + justify-content: space-around; + width: 100%; + margin: 0 auto; + margin-top: 41px; + + &__option { + all: unset; + text-align: center; + font-weight: 400; + font-size: 14px; + width: 80px; + + &--selected { + all: unset; + text-align: center; + font-weight: 400; + font-size: 14px; + width: 80px; + + &::after { + display: block; + content: ""; + border-bottom: 1px solid #ff7f23; + font-size: 14px; + width: 80px; + padding-top: 10px; + margin-bottom: -11px; + } + } + } +} + +.underline { + margin-top: 10px; + border-bottom: 1px solid #eeeeee; +} diff --git a/src/pages/MyPage/components/BoardSelector/index.tsx b/src/pages/MyPage/components/BoardSelector/index.tsx new file mode 100644 index 00000000..e390d750 --- /dev/null +++ b/src/pages/MyPage/components/BoardSelector/index.tsx @@ -0,0 +1,20 @@ +import useMediaQuery from 'utils/hooks/useMediaQuery'; +import styles from './BoardSelector.module.scss'; + +type SelectorProps = { + setBoard: (type:string)=>void, + board: string +}; + +export default function BoardSelector({ setBoard, board }:SelectorProps) { + const { isMobile } = useMediaQuery(); + return ( + <> +
+ + +
+
+ + ); +} diff --git a/src/pages/MyPage/components/BookMark/BookMark.module.scss b/src/pages/MyPage/components/BookMark/BookMark.module.scss new file mode 100644 index 00000000..edd905ac --- /dev/null +++ b/src/pages/MyPage/components/BookMark/BookMark.module.scss @@ -0,0 +1,112 @@ +@use "src/utils/styles/mediaQuery" as media; + +.bookmarks { + width: 91%; + margin: 0 auto; + + @include media.media-breakpoint-down(mobile) { + height: calc(100vh - 228px); + overflow-y: scroll; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + &__total { + display: block; + font-size: 14px; + font-weight: 500; + color: #c4c4c4; + margin-bottom: 8px; + margin-top: 16px; + } +} + +.bookmark { + &::after { + display: block; + content: ""; + width: 100%; + margin: 16px 0; + border-bottom: 1px solid #eeeeee; + } + + &__detail { + display: flex; + gap: 2px; + + &--name { + font-size: 18px; + font-weight: 700; + } + + &--type { + display: flex; + align-items: end; + font-size: 14px; + font-weight: 400; + color: #979797; + } + } + + &__star-rate { + display: flex; + gap: 1px; + + &--image { + width: 16px; + height: 16px; + } + + &--rate { + line-height: 17px; + font-size: 14px; + font-weight: 400; + color: #666666; + } + } + + &__store-image { + width: 100%; + height: 200px; + position: relative; + overflow: hidden; + + @include media.media-breakpoint-down(mobile) { + height: 96px; + } + + &--crop { + width: 100%; + position: absolute; + } + } +} + +.not-exist { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + &__phrase { + display: flex; + gap: 9px; + flex-direction: column; + text-align: center; + font-size: 14px; + color: #666666; + white-space: pre; + } + + &__image { + margin-top: 24px; + width: 207px; + height: 158px; + } +} diff --git a/src/pages/MyPage/components/BookMark/index.tsx b/src/pages/MyPage/components/BookMark/index.tsx new file mode 100644 index 00000000..b5eee16c --- /dev/null +++ b/src/pages/MyPage/components/BookMark/index.tsx @@ -0,0 +1,48 @@ +import filledStar from 'assets/svg/mypage/star-filled.svg'; +import defaultImage from 'assets/images/search/default-image.png'; +import useScraps from 'pages/MyPage/hooks/useScraps'; +import useObserver from 'pages/MyPage/hooks/useObeserver'; +import notExist from 'assets/svg/mypage/not-exist.svg'; +import styles from './BookMark.module.scss'; + +export default function BookMark() { + const { + scraps, isLoading, fetchNextPage, total, + } = useScraps(); + const { target: bottom } = useObserver(fetchNextPage); + return ( +
+ {!isLoading && scraps && ( + <> + {`총 ${total}개의 음식점`} + {scraps.map((scrap) => ( +
+
+ {scrap.name} + {scrap.category} +
+
+ satr-rate + {(scrap.totalRating / scrap.ratingCount).toFixed(1)} +
+
+ store +
+
+ ))} + + )} + {!isLoading && !scraps + && ( +
+ +

등록된 북마크가 없어요.

+

새로운 음식점을 저장해 보세요!

+
+ not-exist +
+ )} +
+
+ ); +} diff --git a/src/pages/MyPage/components/Information/Information.module.scss b/src/pages/MyPage/components/Information/Information.module.scss new file mode 100644 index 00000000..afc1b310 --- /dev/null +++ b/src/pages/MyPage/components/Information/Information.module.scss @@ -0,0 +1,109 @@ +@use "src/utils/styles/mediaQuery" as media; + +.information { + display: flex; + justify-content: space-between; + align-items: center; + width: 91%; + margin: 0 auto; +} + +.user { + display: flex; + align-items: center; + justify-content: start; + + &__image { + width: 46px; + height: 46px; + border-radius: 50%; + } + + &__nickname { + margin: 0; + margin-bottom: 15px; + display: inline; + font-size: 16px; + font-weight: 700; + } + + &__account { + display: block; + font-size: 14px; + font-weight: 400; + } +} + +.totals { + width: 174px; + display: flex; + justify-content: space-between; + + &__post { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + &--number { + font-size: 40px; + font-weight: 500; + } + + &--label { + font-size: 24px; + font-weight: 400; + } + } + + &__follower { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + &--number { + font-size: 14px; + font-weight: 500; + } + + &--label { + font-size: 24px; + font-weight: 400; + } + } +} + +.dot { + display: inline-block; + width: 0; + height: 0; + border: 1px solid black; + border-radius: 100; + margin: 5px; +} + +.option { + width: 24px; + height: 24px; +} + +.profile { + all: unset; + display: flex; + position: relative; + outline: none; + border-radius: 50%; + margin-right: 16px; + cursor: pointer; + + &:hover { + filter: brightness(50%); + } +} + +.change--image { + position: absolute; + top: 25%; + left: 25%; +} diff --git a/src/pages/MyPage/components/Information/index.tsx b/src/pages/MyPage/components/Information/index.tsx new file mode 100644 index 00000000..d69ceab8 --- /dev/null +++ b/src/pages/MyPage/components/Information/index.tsx @@ -0,0 +1,42 @@ +import defaultImage from 'assets/images/follow/default-image.png'; +import option from 'assets/svg/mypage/option.svg'; +import { Link } from 'react-router-dom'; +import { User } from 'api/user/entity'; +import styles from './Information.module.scss'; + +type Profile = User & { + profileImage?: { + url: string + }, +}; +interface InformationProps { + openModal:(url:string | undefined) => void, + profile: Profile + followerNumber?: number + +} + +export default function Information({ openModal, followerNumber, profile }:InformationProps) { + return ( +
+
+ +
+ + {profile?.nickname} + +
+ + {`팔로워 ${followerNumber}`} + + {`@${profile?.account}`} +
+
+ + option + +
+ ); +} diff --git a/src/pages/MyPage/components/MobileBoard/MobileBoard.module.scss b/src/pages/MyPage/components/MobileBoard/MobileBoard.module.scss new file mode 100644 index 00000000..4633993e --- /dev/null +++ b/src/pages/MyPage/components/MobileBoard/MobileBoard.module.scss @@ -0,0 +1,131 @@ +@use "src/utils/styles/mediaQuery" as media; + +.board { + width: 100%; + height: calc(100vh - 228px); + + @include media.media-breakpoint-down(mobile) { + overflow-y: scroll; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } +} + +.total { + display: block; + width: 91%; + margin: 0 auto; + margin-bottom: 20px; + margin-top: 20px; + font-size: 16px; + font-weight: 700; + color: #ff7f23; + line-height: 20px; +} + +.post { + width: 91%; + margin: 0 auto; + margin-top: 16px; + + &__store { + display: flex; + justify-content: space-between; + } + + &__opener { + width: 24px; + height: 24px; + display: flex; + align-items: center; + border: 0; + background-color: transparent; + justify-content: center; + } +} + +.store { + display: flex; + margin-bottom: 8px; + align-items: end; + gap: 4px; + + &__name { + font-size: 18px; + font-weight: 700; + } + + &__type { + font-size: 12px; + font-weight: 500; + color: #c4c4c4; + } +} + +.underline { + border-bottom: 3px solid #eeeeee; +} + +.review { + display: flex; + align-items: center; + + &__main-text { + font-size: 14px; + line-height: 17.47px; + margin-bottom: 8px; + } + + &__detail { + display: flex; + gap: 4px; + font-size: 14px; + color: #666666; + align-items: center; + text-align: center; + margin-bottom: 16px; + } +} + +.star-rate { + display: flex; + align-items: center; + + &__image { + width: 17px; + height: 17px; + } + + &__rate { + font-size: 14px; + } +} + +.not-exist { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + &__phrase { + display: flex; + gap: 9px; + flex-direction: column; + text-align: center; + font-size: 14px; + color: #666666; + white-space: pre; + } + + &__image { + margin-top: 24px; + width: 207px; + height: 158px; + } +} diff --git a/src/pages/MyPage/components/MobileBoard/index.tsx b/src/pages/MyPage/components/MobileBoard/index.tsx new file mode 100644 index 00000000..6df2dade --- /dev/null +++ b/src/pages/MyPage/components/MobileBoard/index.tsx @@ -0,0 +1,86 @@ +import filledStar from 'assets/svg/mypage/star-filled.svg'; +import openArrow from 'assets/svg/mypage/open-arrow.svg'; +import closeArrow from 'assets/svg/mypage/close-arrow.svg'; +import { useState } from 'react'; +import notExist from 'assets/svg/mypage/not-exist.svg'; +import { Shop } from 'api/mypage/entity'; +import useReviwes from 'pages/MyPage/hooks/useReviews'; +import useMyProfile from 'pages/MyPage/hooks/useMyProfile'; +import styles from './MobileBoard.module.scss'; + +interface MobileBoardProps { + posts: Array, +} +interface ReviewProps { + placeId: string, + name:string, + category:string +} +function Review({ + placeId, name, category, +}:ReviewProps) { + const [isOpen, setOpen] = useState(false); + const { reviews, isLoading } = useReviwes(placeId); + return ( +
+ + {!isLoading && reviews.map((review) => ( + isOpen && ( +
+

{review.content}

+
+ {review.createdAt} + | +
+ rate + + {parseFloat(review.rate.toString()).toFixed(1)} + +
+
+
+ ) + + ))} +
+ ); +} + +export default function MoileBoard({ posts }:MobileBoardProps) { + const { getTotal } = useMyProfile(); + return ( +
+ {posts ? ( + <> + {`총 ${getTotal()}개의 리뷰`} + {posts.map((post) => ( +
+ +
+
+ ))} + + ) : ( +
+ +

등록된 리뷰가 없어요.

+

다녀온 음식점의 리뷰를 작성해 보세요!

+
+ not-exist +
+ )} +
+ ); +} diff --git a/src/pages/MyPage/components/MyPost/MyPost.module.scss b/src/pages/MyPage/components/MyPost/MyPost.module.scss new file mode 100644 index 00000000..5f412bf2 --- /dev/null +++ b/src/pages/MyPage/components/MyPost/MyPost.module.scss @@ -0,0 +1,42 @@ +@use "src/utils/styles/mediaQuery" as media; + +.view-options { + display: flex; + width: 98%; + gap: 17px; + justify-content: end; + margin-top: 25px; + + @include media.media-breakpoint-down(mobile) { + display: none; + } + + &__option { + all: unset; + } +} + +.not-exist { + width: 100%; + height: calc(100vh - 228px); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + &__phrase { + display: flex; + gap: 9px; + flex-direction: column; + text-align: center; + font-size: 14px; + color: #666666; + white-space: pre; + } + + &__image { + margin-top: 24px; + width: 207px; + height: 158px; + } +} diff --git a/src/pages/MyPage/components/MyPost/index.tsx b/src/pages/MyPage/components/MyPost/index.tsx new file mode 100644 index 00000000..30ec819f --- /dev/null +++ b/src/pages/MyPage/components/MyPost/index.tsx @@ -0,0 +1,24 @@ +import notExist from 'assets/svg/mypage/not-exist.svg'; +import useReviwedShops from 'pages/MyPage/hooks/useReviewedShops'; +import styles from './MyPost.module.scss'; +import MobileBoard from '../MobileBoard'; + +export default function MyPost() { + const { isLoading, shops } = useReviwedShops(); + return ( +
+ {!isLoading && shops && ( + + ) } + {!isLoading && !shops && ( +
+ +

등록된 리뷰가 없어요.

+

다녀온 음식점의 리뷰를 작성해 보세요!

+
+ not-exist +
+ )} +
+ ); +} diff --git a/src/pages/MyPage/components/ProfileModal/ProfileModal.module.scss b/src/pages/MyPage/components/ProfileModal/ProfileModal.module.scss new file mode 100644 index 00000000..fd5b4e47 --- /dev/null +++ b/src/pages/MyPage/components/ProfileModal/ProfileModal.module.scss @@ -0,0 +1,106 @@ +.phrase { + display: block; + color: #000000; + font-size: 18px; + font-weight: 700; + line-height: 22.46px; + margin-bottom: 30px; +} + +.form { + &__image-input { + display: none; + } + + &__upload { + background: inherit; + border: none; + box-shadow: none; + border-radius: 0; + padding: 0; + display: block; + margin: 0 auto; + margin-bottom: 25px; + + &--image { + width: 100px; + height: 100px; + margin: 0 auto; + position: relative; + } + + &--profile { + width: 100px; + height: 100px; + border-radius: 50%; + } + + &--plus { + position: absolute; + bottom: 3px; + right: 3px; + border-radius: 50%; + } + } + + &__name { + position: relative; + margin: 0 auto; + width: 48%; + margin-bottom: 50px; + } + + &__buttons { + display: flex; + width: 100%; + justify-content: space-between; + margin: 0 auto; + + &--cancel { + all: unset; + width: 45%; + height: 40px; + font-size: 16px; + color: #ffffff; + background-color: #c4c4c4; + outline: 0; + text-align: center; + border-radius: 999px; + } + + &--complete { + all: unset; + width: 45%; + height: 40px; + font-size: 16px; + color: #ffffff; + background-color: #ff7f23; + outline: 0; + text-align: center; + border-radius: 999px; + } + } +} + +.name { + &__input { + display: block; + width: calc(100% - 40px); + border: none; + margin: 0 auto; + border-bottom: 1px solid #c4c4c4; + padding: 4px 10px; + padding-right: 30px; + position: relative; + outline: none; + } + + &__length { + display: block; + width: 20px; + right: 3px; + top: 7px; + position: absolute; + font-size: 10px; + } +} diff --git a/src/pages/MyPage/components/ProfileModal/index.tsx b/src/pages/MyPage/components/ProfileModal/index.tsx new file mode 100644 index 00000000..afe5d872 --- /dev/null +++ b/src/pages/MyPage/components/ProfileModal/index.tsx @@ -0,0 +1,66 @@ +import defaultImage from 'assets/images/follow/default-image.png'; +import plus from 'assets/svg/mypage/plus.svg'; +import { toast } from 'react-toastify'; +import React, { useState } from 'react'; +import useChangeProfile from 'pages/MyPage/hooks/useChangeProfile'; +import useChangeNickname from 'pages/MyPage/hooks/useChangeNickname'; +import styles from './ProfileModal.module.scss'; + +interface ProfileModalProps { + imgUrl:string | undefined, + nickname?:string +} + +export default function ProfileModal({ imgUrl, nickname }:ProfileModalProps) { + const [nameLength, setName] = useState(nickname?.length); + const changeName = (e:React.ChangeEvent) => { + if (e.target.value.length <= 10) { + setName(e.target.value.length); + } + }; + const closeModal = () => { + toast.dismiss(); + }; + const { + onChange, onClick: changeProfile, previewUrl, + } = useChangeProfile(); + const { onClick: changeNickname, nicknameRef } = useChangeNickname(); + + const onClick = (e:React.FormEvent) => { + e.preventDefault(); + changeProfile(); + if (nicknameRef.current) { + changeNickname(nicknameRef.current.value); + } + closeModal(); + }; + + return ( + <> + + {nickname} + 님, +
+ 프로필을 변경하시겠어요? +
+
onClick(e)}> + +
+ changeName(e)} className={styles.name__input} maxLength={10} ref={nicknameRef} /> + {`${nameLength}/10`} +
+
+ + +
+
+ + ); +} diff --git a/src/pages/MyPage/hooks/useChangeNickname.ts b/src/pages/MyPage/hooks/useChangeNickname.ts new file mode 100644 index 00000000..7105ad85 --- /dev/null +++ b/src/pages/MyPage/hooks/useChangeNickname.ts @@ -0,0 +1,19 @@ +import { patchNickname } from 'api/mypage'; +import { useRef } from 'react'; +import { useMutation, useQueryClient } from 'react-query'; + +const useChangeNickname = () => { + const nicknameRef = useRef(null); + const queryClient = useQueryClient(); + const changeNickname = useMutation( + (nickname:string) => patchNickname(nickname), + { onSuccess: () => queryClient.invalidateQueries('profile') }, + ); + const onClick = (nickname:string) => { + changeNickname.mutate(nickname); + }; + + return { onClick, nicknameRef }; +}; + +export default useChangeNickname; diff --git a/src/pages/MyPage/hooks/useChangeProfile.ts b/src/pages/MyPage/hooks/useChangeProfile.ts new file mode 100644 index 00000000..2703a5f7 --- /dev/null +++ b/src/pages/MyPage/hooks/useChangeProfile.ts @@ -0,0 +1,38 @@ +import { patchProfileImage } from 'api/mypage'; +import React, { useState } from 'react'; +import { useMutation, useQueryClient } from 'react-query'; + +const useChangeProfile = () => { + const [image, setImage] = useState(null); + const [previewUrl, setUrl] = useState(null); + const queryClient = useQueryClient(); + const changeImage = useMutation(() => patchProfileImage(image), { + onSuccess: () => { + queryClient.invalidateQueries('profile'); + }, + }); + const getImageUrl = (file:Blob) => { + const reader = new FileReader(); + reader.onload = () => { + setUrl(reader.result as string); + }; + reader.readAsDataURL(file); + }; + const onChange = (e:React.ChangeEvent) => { + const formData = new FormData(); + if (e.target.files) { + const imageFile = e.target.files[0]; + formData.append('profile', imageFile); + setImage(formData); + getImageUrl(e.target.files[0]); + } + }; + const onClick = () => { + if (image) { + changeImage.mutate(); + } + }; + return { previewUrl, onChange, onClick }; +}; + +export default useChangeProfile; diff --git a/src/pages/MyPage/hooks/useMyProfile.ts b/src/pages/MyPage/hooks/useMyProfile.ts new file mode 100644 index 00000000..9cfc2453 --- /dev/null +++ b/src/pages/MyPage/hooks/useMyProfile.ts @@ -0,0 +1,22 @@ +import { getFollwers } from 'api/mypage'; +import { getMe } from 'api/user'; +import { User } from 'api/user/entity'; +import { useQuery } from 'react-query'; + +type Profile = User & { + profileImage?: { + url: string + }, +}; +const useMyProfile = () => { + const { data: profileData, isLoading } = useQuery('profile', getMe); + const { data: followers } = useQuery('myFollowers', getFollwers); + const profile:Profile | null = profileData ? profileData.data : null; + const getTotal = () => (profileData ? profileData.data.userCountResponse.reviewCount : 0); + const followerNumber = followers?.data.content.length; + return { + profile, isLoading, getTotal, followerNumber, + }; +}; + +export default useMyProfile; diff --git a/src/pages/MyPage/hooks/useObeserver.ts b/src/pages/MyPage/hooks/useObeserver.ts new file mode 100644 index 00000000..67d61ee9 --- /dev/null +++ b/src/pages/MyPage/hooks/useObeserver.ts @@ -0,0 +1,28 @@ +import { useCallback, useEffect, useRef } from 'react'; + +const useObserver = ( + callback: () => void, +) => { + const target = useRef(null); + const onIntersect = useCallback( + (entries: IntersectionObserverEntry[]) => { + const [entry] = entries; + if (entry.isIntersecting) { + callback(); + } + }, + [callback], + ); + useEffect(() => { + let observer:IntersectionObserver; + if (target && target.current) { + observer = new IntersectionObserver(onIntersect); + observer.observe(target.current); + } + return () => observer && observer.disconnect(); + }, [target, onIntersect]); + + return { target }; +}; + +export default useObserver; diff --git a/src/pages/MyPage/hooks/useReviewedShops.ts b/src/pages/MyPage/hooks/useReviewedShops.ts new file mode 100644 index 00000000..873bc245 --- /dev/null +++ b/src/pages/MyPage/hooks/useReviewedShops.ts @@ -0,0 +1,10 @@ +import { getReviewedShops } from 'api/mypage'; +import { useQuery } from 'react-query'; + +const useReviwedShops = () => { + const { isLoading, isError, data } = useQuery('reviewedShops', () => getReviewedShops()); + const shops = data ? data.data.content : []; + return { isLoading, isError, shops }; +}; + +export default useReviwedShops; diff --git a/src/pages/MyPage/hooks/useReviews.ts b/src/pages/MyPage/hooks/useReviews.ts new file mode 100644 index 00000000..0b0a67d7 --- /dev/null +++ b/src/pages/MyPage/hooks/useReviews.ts @@ -0,0 +1,10 @@ +import { getReviews } from 'api/mypage'; +import { useQuery } from 'react-query'; + +const useReviwes = (placeId:string) => { + const { isLoading, isError, data } = useQuery(['reviews', placeId], () => getReviews(placeId)); + const reviews = data ? data.data.content : []; + return { isLoading, isError, reviews }; +}; + +export default useReviwes; diff --git a/src/pages/MyPage/hooks/useScraps.ts b/src/pages/MyPage/hooks/useScraps.ts new file mode 100644 index 00000000..fd91a878 --- /dev/null +++ b/src/pages/MyPage/hooks/useScraps.ts @@ -0,0 +1,25 @@ +import { getScraps } from 'api/mypage'; +import { useInfiniteQuery } from 'react-query'; + +const useScraps = () => { + const { data, isLoading, fetchNextPage } = useInfiniteQuery({ + queryKey: ['scraps'], + queryFn: ({ pageParam = 0 }) => getScraps(pageParam), + // eslint-disable-next-line consistent-return + getNextPageParam: (lastResponse, allResponse) => { + const currentPage = allResponse.length - 1; + if (lastResponse.data.totalPages > currentPage) return currentPage + 1; + }, + select: (response) => ({ + pages: response.pages.flatMap((page) => [page.data]), + pageParams: response.pageParams, + }), + }); + const scraps = data?.pages.flatMap((page) => page.content); + const total = data?.pages[0].totalElements; + return { + scraps, isLoading, fetchNextPage, data, total, + }; +}; + +export default useScraps; diff --git a/src/pages/MyPage/index.tsx b/src/pages/MyPage/index.tsx new file mode 100644 index 00000000..f7578e1e --- /dev/null +++ b/src/pages/MyPage/index.tsx @@ -0,0 +1,47 @@ +import BottomNavigation from 'components/common/BottomNavigation'; +import SideNavigation from 'components/common/SideNavigation'; +import useMediaQuery from 'utils/hooks/useMediaQuery'; +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import Information from './components/Information'; +import BoardSelector from './components/BoardSelector'; +import BookMark from './components/BookMark'; +import styles from './MyPage.module.scss'; +import MyPost from './components/MyPost'; +import ProfileModal from './components/ProfileModal'; +import useMyProfile from './hooks/useMyProfile'; + +export default function MyPage() { + const { isMobile } = useMediaQuery(); + const [board, setBoard] = useState('MYPOST'); + const [isOpen, setOpen] = useState(false); + const { profile, followerNumber } = useMyProfile(); + + const openModal = (url:string | undefined) => { + setOpen(true); + toast(, { + position: 'bottom-center', + autoClose: false, + closeOnClick: false, + closeButton: false, + draggable: false, + onClose: () => setOpen(false), + className: styles['bottom-sheet'], + }); + }; + return ( + <> + {!isMobile && } + {profile && ( +
+ + + {board === 'MYPOST' && } + {board === 'BOOKMARK' && } +
+ )} + {isMobile && } + {isOpen &&
} + + ); +}