diff --git a/.github/workflows/sonar-cloud-analysis-build.yml b/.github/workflows/sonar-cloud-analysis-build.yml index d7f57f4c..10ae7a2d 100644 --- a/.github/workflows/sonar-cloud-analysis-build.yml +++ b/.github/workflows/sonar-cloud-analysis-build.yml @@ -9,12 +9,11 @@ jobs: name: sonar analysis runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Install dependencies - run: npm install - - name: install sonarqube-scanner - run: npm install sonarqube-scanner -D - - name : run sonar - run: npm run sonar + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/package.json b/package.json index b274a60f..39642673 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,7 @@ "build-storybook": "build-storybook -s public", "pretty": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json}\"", "predeploy": "npm run build && cp build/index.html build/404.html", - "deploy": "gh-pages -d build", - "sonar": "node sonar-project.js" + "deploy": "gh-pages -d build" }, "eslintConfig": { "extends": [ diff --git a/sonar-project.js b/sonar-project.js deleted file mode 100644 index c475e47f..00000000 --- a/sonar-project.js +++ /dev/null @@ -1,10 +0,0 @@ -const sonarqubeScanner = require("sonarqube-scanner"); -sonarqubeScanner( - { - serverUrl: "https://sonarcloud.io", - options: { - "sonar.sources": "./src", - }, - }, - () => {}, -); diff --git a/src/api/bookmark/fetchBookmarkList.ts b/src/api/bookmark/fetchBookmarkList.ts index f1dacc4b..e8e12d12 100644 --- a/src/api/bookmark/fetchBookmarkList.ts +++ b/src/api/bookmark/fetchBookmarkList.ts @@ -1,20 +1,18 @@ -import type { BookmarkStore } from "types/common/bookmarkTypes"; +import { BookmarkStore, BookmarkStoreServerResponse } from "types/common"; import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; +import { MESSAGES } from "constants/messages"; import axiosInstance from "api/axiosInstance"; -const fetchBookmarkList = async () => { +const fetchBookmarkList = async (): Promise => { const accessToken = window.sessionStorage.getItem(ACCESS_TOKEN); if (!accessToken) { - window.sessionStorage.removeItem(ACCESS_TOKEN); - window.alert("다시 로그인 해주세요"); - window.location.href = "/"; - return; + throw new Error(MESSAGES.LOGIN_REQUIRED); } - const { data } = await axiosInstance.get( + const { data } = await axiosInstance.get( ENDPOINTS.BOOKMARKS, { headers: { @@ -23,7 +21,14 @@ const fetchBookmarkList = async () => { } ); - return data; + const formattedData: BookmarkStore[] = data.map((bookmarkStore) => { + return { + ...bookmarkStore, + thumbnailUrl: bookmarkStore.imageUrl, + }; + }); + + return formattedData; }; export default fetchBookmarkList; diff --git a/src/api/bookmark/sendBookmarkDeleteRequest.ts b/src/api/bookmark/sendBookmarkDeleteRequest.ts index d0103fea..423aa1ff 100644 --- a/src/api/bookmark/sendBookmarkDeleteRequest.ts +++ b/src/api/bookmark/sendBookmarkDeleteRequest.ts @@ -1,14 +1,13 @@ import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; +import { MESSAGES } from "constants/messages"; import axiosInstance from "api/axiosInstance"; -const sendBookmarkDeleteRequest = (restaurantId: number) => () => { +const sendBookmarkDeleteRequest = (restaurantId: number) => { const accessToken = window.sessionStorage.getItem(ACCESS_TOKEN); if (!accessToken) { - window.sessionStorage.removeItem(ACCESS_TOKEN); - window.alert("로그인 해주세요"); - window.location.reload(); + throw new Error(MESSAGES.LOGIN_REQUIRED); } return axiosInstance.delete(ENDPOINTS.BOOKMARK_STORE(restaurantId), { diff --git a/src/api/bookmark/sendBookmarkPostRequest.ts b/src/api/bookmark/sendBookmarkPostRequest.ts index eb211200..5fa132b6 100644 --- a/src/api/bookmark/sendBookmarkPostRequest.ts +++ b/src/api/bookmark/sendBookmarkPostRequest.ts @@ -1,14 +1,13 @@ import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; +import { MESSAGES } from "constants/messages"; import axiosInstance from "api/axiosInstance"; -const sendBookmarkPostRequest = (restaurantId: number) => () => { +const sendBookmarkPostRequest = (restaurantId: number) => { const accessToken = window.sessionStorage.getItem(ACCESS_TOKEN); if (!accessToken) { - window.sessionStorage.removeItem(ACCESS_TOKEN); - window.alert("로그인 해주세요"); - window.location.reload(); + throw new Error(MESSAGES.LOGIN_REQUIRED); } return axiosInstance.post(ENDPOINTS.BOOKMARK_STORE(restaurantId), null, { diff --git a/src/api/image/sendImageUploadPostRequest.ts b/src/api/image/sendImageUploadPostRequest.ts index 282ae178..5f1065fd 100644 --- a/src/api/image/sendImageUploadPostRequest.ts +++ b/src/api/image/sendImageUploadPostRequest.ts @@ -1,6 +1,7 @@ import { AxiosResponse } from "axios"; import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; +import { MESSAGES } from "constants/messages"; import axiosInstance from "api/axiosInstance"; @@ -13,9 +14,8 @@ const sendImageUploadPostRequest = async (imageFile: FormData) => { if (!accessToken) { window.sessionStorage.removeItem(ACCESS_TOKEN); - window.alert("다시 로그인 해주세요"); - window.location.reload(); - throw new Error("엑세스토큰이 유효하지 않습니다"); + + throw new Error(MESSAGES.LOGIN_REQUIRED); } const response: AxiosResponse = await axiosInstance.post( diff --git a/src/api/mypage/fetchUserProfile.ts b/src/api/mypage/fetchUserProfile.ts index 8a85fe2b..b29da51b 100644 --- a/src/api/mypage/fetchUserProfile.ts +++ b/src/api/mypage/fetchUserProfile.ts @@ -1,6 +1,7 @@ import type { UserProfileInformation } from "types/common"; import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; +import { MESSAGES } from "constants/messages"; import axiosInstance from "api/axiosInstance"; @@ -8,10 +9,7 @@ const fetchUserProfile = async () => { const accessToken = window.sessionStorage.getItem(ACCESS_TOKEN); if (!accessToken) { - window.sessionStorage.removeItem(ACCESS_TOKEN); - window.alert("다시 로그인 해주세요"); - window.location.href = "/"; - return; + throw new Error(MESSAGES.LOGIN_REQUIRED); } const { data } = await axiosInstance.get( diff --git a/src/api/mypage/fetchUserReviewList.ts b/src/api/mypage/fetchUserReviewList.ts index ba04f98f..870af40e 100644 --- a/src/api/mypage/fetchUserReviewList.ts +++ b/src/api/mypage/fetchUserReviewList.ts @@ -1,23 +1,29 @@ import { FetchParamProps } from "types/apiTypes"; -import type { UserReview } from "types/common"; +import { UserReview, UserReviewServerResponse } from "types/common"; import { ACCESS_TOKEN, ENDPOINTS, SIZE } from "constants/api"; +import { MESSAGES } from "constants/messages"; import axiosInstance from "api/axiosInstance"; interface UserReviewResponse { hasNext: boolean; + reviews: UserReviewServerResponse[]; +} + +interface FetchUserReviewListResult { + hasNext: boolean; + nextPageParam: number; reviews: UserReview[]; } -const fetchUserReviewList = async ({ pageParam = 0 }: FetchParamProps) => { +const fetchUserReviewList = async ({ + pageParam = 0, +}: FetchParamProps): Promise => { const accessToken = window.sessionStorage.getItem(ACCESS_TOKEN); if (!accessToken) { - window.sessionStorage.removeItem(ACCESS_TOKEN); - window.alert("다시 로그인 해주세요"); - window.location.href = "/"; - throw new Error("엑세스토큰이 유효하지 않습니다"); + throw new Error(MESSAGES.LOGIN_REQUIRED); } const { data } = await axiosInstance.get( @@ -30,7 +36,21 @@ const fetchUserReviewList = async ({ pageParam = 0 }: FetchParamProps) => { } ); - return { ...data, nextPageParam: pageParam + 1 }; + const formattedReviews = data.reviews.map((review) => { + return { + ...review, + restaurant: { + ...review.restaurant, + thumbnailUrl: review.restaurant.imageUrl, + }, + }; + }); + + return { + reviews: formattedReviews, + hasNext: data.hasNext, + nextPageParam: pageParam + 1, + }; }; export default fetchUserReviewList; diff --git a/src/api/review/deleteReviewItem.tsx b/src/api/review/deleteReviewItem.tsx index dce7a7be..d7c9cfa5 100644 --- a/src/api/review/deleteReviewItem.tsx +++ b/src/api/review/deleteReviewItem.tsx @@ -1,6 +1,7 @@ import { AxiosResponse } from "axios"; import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; +import { MESSAGES } from "constants/messages"; import axiosInstance from "api/axiosInstance"; @@ -15,10 +16,7 @@ const deleteReviewItem = async ({ }: DeleteReviewItemProp) => { const accessToken = window.sessionStorage.getItem(ACCESS_TOKEN); if (!accessToken) { - window.sessionStorage.removeItem(ACCESS_TOKEN); - window.alert("다시 로그인 해주세요"); - window.location.reload(); - return; + throw new Error(MESSAGES.LOGIN_REQUIRED); } const { data } = await axiosInstance.delete( diff --git a/src/api/review/sendReviewItem.tsx b/src/api/review/sendReviewItem.tsx index 76abdaef..d79583e7 100644 --- a/src/api/review/sendReviewItem.tsx +++ b/src/api/review/sendReviewItem.tsx @@ -1,15 +1,17 @@ import { AxiosResponse } from "axios"; import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; +import { MESSAGES } from "constants/messages"; import axiosInstance from "api/axiosInstance"; -interface SendReviewItemProps { +export interface SendReviewItemProps { restaurantId: string; articleId: string; content: string; rating: number; menu: string; + imageUrl: string; } const sendReviewItem = async ({ @@ -18,13 +20,11 @@ const sendReviewItem = async ({ content, rating, menu, + imageUrl, }: SendReviewItemProps) => { const accessToken = window.sessionStorage.getItem(ACCESS_TOKEN); if (!accessToken) { - window.sessionStorage.removeItem(ACCESS_TOKEN); - window.alert("다시 로그인 해주세요"); - window.location.reload(); - return; + throw new Error(MESSAGES.LOGIN_REQUIRED); } const { data } = await axiosInstance.put( ENDPOINTS.UPDATE_REVIEW_ITEM(restaurantId, articleId), @@ -32,6 +32,7 @@ const sendReviewItem = async ({ content: content, rating: rating, menu: menu, + imageUrl, }, { headers: { diff --git a/src/api/review/sendReviewPostRequest.tsx b/src/api/review/sendReviewPostRequest.tsx index 507d798f..c66241d8 100644 --- a/src/api/review/sendReviewPostRequest.tsx +++ b/src/api/review/sendReviewPostRequest.tsx @@ -1,6 +1,7 @@ import { ReviewInputShape } from "types/common"; import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; +import { MESSAGES } from "constants/messages"; import axiosInstance from "api/axiosInstance"; @@ -8,10 +9,7 @@ const sendReviewPostRequest = (restaurantId: string) => (newReview: ReviewInputShape) => { const accessToken = window.sessionStorage.getItem(ACCESS_TOKEN); if (!accessToken) { - window.sessionStorage.removeItem(ACCESS_TOKEN); - window.alert("다시 로그인 해주세요"); - window.location.reload(); - throw new Error("엑세스토큰이 유효하지 않습니다"); + throw new Error(MESSAGES.LOGIN_REQUIRED); } return axiosInstance.post(ENDPOINTS.REVIEWS(restaurantId), newReview, { headers: { diff --git a/src/api/store/fetchAutoCompleteStoreList.tsx b/src/api/store/fetchAutoCompleteStoreList.tsx new file mode 100644 index 00000000..3240ffa2 --- /dev/null +++ b/src/api/store/fetchAutoCompleteStoreList.tsx @@ -0,0 +1,31 @@ +import { CampusId, AutoCompleteOption } from "types/common"; + +import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; + +import axiosInstance from "api/axiosInstance"; + +interface AutoCompleteStoreGetResponse { + restaurants: AutoCompleteOption[]; +} + +const fetchAutoCompleteStoreList = async ( + campusId: CampusId, + keyword: string +) => { + const accessToken = sessionStorage.getItem(ACCESS_TOKEN); + + const userFetchOptions = { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }; + + const { data } = await axiosInstance.get( + ENDPOINTS.AUTO_COMPLETE_STORES(campusId, keyword), + accessToken ? userFetchOptions : undefined + ); + + return data.restaurants; +}; + +export default fetchAutoCompleteStoreList; diff --git a/src/api/store/fetchRandomStoreList.tsx b/src/api/store/fetchRandomStoreList.tsx index b6233c10..f453002d 100644 --- a/src/api/store/fetchRandomStoreList.tsx +++ b/src/api/store/fetchRandomStoreList.tsx @@ -1,10 +1,17 @@ -import { CampusId, Store } from "types/common"; +import { + CampusId, + StoreItemWithHeart, + StoreServerResponse, +} from "types/common"; import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; import axiosInstance from "api/axiosInstance"; -const fetchRandomStoreList = async (campusId: CampusId, size: number) => { +const fetchRandomStoreList = async ( + campusId: CampusId, + size: number +): Promise => { const accessToken = sessionStorage.getItem(ACCESS_TOKEN); const userFetchOptions = { @@ -13,12 +20,19 @@ const fetchRandomStoreList = async (campusId: CampusId, size: number) => { }, }; - const { data } = await axiosInstance.get( + const { data } = await axiosInstance.get( ENDPOINTS.RANDOM_STORES(campusId, size), accessToken ? userFetchOptions : undefined ); - return data; + const formattedData: StoreItemWithHeart[] = data.map((store) => { + return { + ...store, + thumbnailUrl: store.imageUrl, + }; + }); + + return formattedData; }; export default fetchRandomStoreList; diff --git a/src/api/store/fetchStoreDetail.tsx b/src/api/store/fetchStoreDetail.tsx index 6ddc8db2..b0b3bd19 100644 --- a/src/api/store/fetchStoreDetail.tsx +++ b/src/api/store/fetchStoreDetail.tsx @@ -1,10 +1,10 @@ -import { Store } from "types/common"; +import { Store, StoreServerResponse } from "types/common"; import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; import axiosInstance from "api/axiosInstance"; -const fetchStoreDetail = async (restaurantId: string) => { +const fetchStoreDetail = async (restaurantId: string): Promise => { const accessToken = sessionStorage.getItem(ACCESS_TOKEN); const userFetchOptions = { @@ -13,12 +13,17 @@ const fetchStoreDetail = async (restaurantId: string) => { }, }; - const { data } = await axiosInstance.get( + const { data } = await axiosInstance.get( ENDPOINTS.STORE_DETAIL(restaurantId), accessToken ? userFetchOptions : undefined ); - return data; + const formattedData: Store = { + ...data, + thumbnailUrl: data.imageUrl, + }; + + return formattedData; }; export default fetchStoreDetail; diff --git a/src/api/store/fetchStoreList.tsx b/src/api/store/fetchStoreList.tsx index 39ff9e77..b2bfa5b6 100644 --- a/src/api/store/fetchStoreList.tsx +++ b/src/api/store/fetchStoreList.tsx @@ -1,5 +1,9 @@ import { FetchParamProps } from "types/apiTypes"; -import { CampusId, Store } from "types/common"; +import { + CampusId, + StoreItemWithHeart, + StoreServerResponse, +} from "types/common"; import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; @@ -16,9 +20,15 @@ interface GenerateParamsProps { name?: string; } -interface CategoryStoreListResponse { +interface CategoryStoreListServerResponse { hasNext: boolean; - restaurants: Store[]; + restaurants: StoreServerResponse[]; +} + +interface FetchStoreListResult { + hasNext: boolean; + nextPageParam: number; + restaurants: StoreItemWithHeart[]; } const generateParams = (propObject: GenerateParamsProps) => @@ -32,13 +42,18 @@ const generateParams = (propObject: GenerateParamsProps) => {} ); -const fetchStoreList = async ({ pageParam = 0, queryKey }: FetchParamProps) => { +const fetchStoreList = async ({ + pageParam = 0, + queryKey, +}: FetchParamProps): Promise => { const accessToken = sessionStorage.getItem(ACCESS_TOKEN); const [, { size, filter, campusId, categoryId, name, type }] = queryKey; + + const formattedFilterOption = filter === "basic" ? null : filter; const params = generateParams({ page: pageParam, size, - filter, + filter: formattedFilterOption, campusId, categoryId, name, @@ -53,12 +68,25 @@ const fetchStoreList = async ({ pageParam = 0, queryKey }: FetchParamProps) => { }, }; - const { data } = await axiosInstance.get( + const { data } = await axiosInstance.get( ENDPOINTS.STORE_LIST(campusId, type), accessToken ? userFetchOptions : nonUserFetchOptions ); - return { ...data, nextPageParam: pageParam + 1 }; + const formattedData: StoreItemWithHeart[] = data.restaurants.map( + (restaurant) => { + return { + ...restaurant, + thumbnailUrl: restaurant.imageUrl, + }; + } + ); + + return { + restaurants: formattedData, + hasNext: data.hasNext, + nextPageParam: pageParam + 1, + }; }; export default fetchStoreList; diff --git a/src/asset/check.svg b/src/asset/check.svg new file mode 100644 index 00000000..19310e5f --- /dev/null +++ b/src/asset/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/asset/index.ts b/src/asset/index.ts index c4090a2f..e75290ab 100644 --- a/src/asset/index.ts +++ b/src/asset/index.ts @@ -1,10 +1,14 @@ -export { default as CloseIcon } from "./close-icon.svg?react"; -export { default as LogoLight } from "./logo-light.svg?react"; -export { default as PlusIcon } from "./plus-icon.svg?react"; -export { default as SearchIcon } from "./search-icon.svg?react"; -export { default as ImageIcon } from "./image-icon.svg?react"; -export { default as RightIcon } from "./right-icon.svg?react"; -export { default as LeftIcon } from "./left-icon.svg?react"; -export { default as PinIcon } from "./pin-icon.svg?react"; -export { default as ClickedPinIcon } from "./clicked-pin-icon.svg?react"; -export { default as CampusPinIcon } from "./campus-pin-icon.svg?react"; +export { ReactComponent as CloseIcon } from "./close-icon.svg"; +export { ReactComponent as LogoLight } from "./logo-light.svg"; +export { ReactComponent as LogoImageLight } from "./logo-light-image.svg"; +export { ReactComponent as LogoTextLight } from "./logo-light-text.svg"; +export { ReactComponent as PlusIcon } from "./plus-icon.svg"; +export { ReactComponent as SearchIcon } from "./search-icon.svg"; +export { ReactComponent as ImageIcon } from "./image-icon.svg"; +export { ReactComponent as RightIcon } from "./right-icon.svg"; +export { ReactComponent as LeftIcon } from "./left-icon.svg"; +export { ReactComponent as OutwardIcon } from "./outward-icon.svg"; +export { ReactComponent as PinIcon } from "./pin-icon.svg"; +export { ReactComponent as ClickedPinIcon } from "./clicked-pin-icon.svg"; +export { ReactComponent as CampusPinIcon } from "./campus-pin-icon.svg"; +export { ReactComponent as Check } from "./check.svg"; diff --git a/src/asset/logo-light-image.svg b/src/asset/logo-light-image.svg new file mode 100644 index 00000000..034dfde1 --- /dev/null +++ b/src/asset/logo-light-image.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/asset/logo-light-text.svg b/src/asset/logo-light-text.svg new file mode 100644 index 00000000..b7f468e4 --- /dev/null +++ b/src/asset/logo-light-text.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/asset/outward-icon.svg b/src/asset/outward-icon.svg new file mode 100644 index 00000000..0d970dbf --- /dev/null +++ b/src/asset/outward-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/common/AutoComplete/AutoComplete.style.ts b/src/components/common/AutoComplete/AutoComplete.style.ts new file mode 100644 index 00000000..4fcc7e37 --- /dev/null +++ b/src/components/common/AutoComplete/AutoComplete.style.ts @@ -0,0 +1,39 @@ +import styled, { css } from "styled-components"; + +export const Container = styled.div` + position: absolute; + width: 100%; + + background-color: ${({ theme }) => theme.color.white}; + + border: 1px solid ${({ theme }) => theme.color.primaryLight2}; + border-top: 0px; + border-radius: ${({ theme }) => theme.borderRadius.small}; + border-top-left-radius: 0; + border-top-right-radius: 0; +`; + +export const buttonStyle = css` + width: 100%; + height: 5rem; + + display: flex; + justify-content: space-between; + align-items: center; + + padding: 1.4rem; + background-color: ${({ theme }) => theme.color.white}; + border: 0; + + font-weight: 500; + + &:hover:enabled { + background-color: ${({ theme }) => theme.color.primaryLight4}; + } + + &:focus { + background-color: ${({ theme }) => theme.color.primaryLight4}; + box-shadow: none; + outline: 0; + } +`; diff --git a/src/components/common/AutoComplete/AutoComplete.tsx b/src/components/common/AutoComplete/AutoComplete.tsx new file mode 100644 index 00000000..6470bc97 --- /dev/null +++ b/src/components/common/AutoComplete/AutoComplete.tsx @@ -0,0 +1,40 @@ +import Button from "../Button/Button"; +import * as S from "./AutoComplete.style"; +import { Link } from "react-router-dom"; +import { AutoCompleteOption } from "types/common"; + +import { PATHNAME } from "constants/routes"; + +import { OutwardIcon } from "asset"; + +interface AutoCompleteProps { + optionList: AutoCompleteOption[]; + onOptionFocus: (option: string) => void; + closeAutoComplete: () => void; +} + +function AutoComplete({ + optionList, + onOptionFocus, + closeAutoComplete, +}: AutoCompleteProps) { + return ( + + {optionList.map((option) => ( + + + + ))} + + ); +} + +export default AutoComplete; diff --git a/src/components/common/BottomSheet/BottomSheet.tsx b/src/components/common/BottomSheet/BottomSheet.tsx index 86390c8d..808a8037 100644 --- a/src/components/common/BottomSheet/BottomSheet.tsx +++ b/src/components/common/BottomSheet/BottomSheet.tsx @@ -3,15 +3,25 @@ import React from "react"; import { useEffect, useState } from "react"; import ReactDOM from "react-dom"; +import { CSSProp } from "styled-components"; + import * as S from "components/common/BottomSheet/BottomSheet.style"; interface BottomSheetProps { title: string; children: React.ReactNode; + cssProps?: { + heading: CSSProp; + }; closeSheet: () => void; } -function BottomSheet({ title, closeSheet, children }: BottomSheetProps) { +function BottomSheet({ + title, + closeSheet, + cssProps, + children, +}: BottomSheetProps) { const [scrollOffset, setScrollOffset] = useState(0); useEffect(() => { @@ -28,7 +38,9 @@ function BottomSheet({ title, closeSheet, children }: BottomSheetProps) { - {title} + + {title} + {children} , diff --git a/src/components/common/Chip/Chip.style.tsx b/src/components/common/Chip/Chip.style.tsx index 2b8a7609..a5c9d8c3 100644 --- a/src/components/common/Chip/Chip.style.tsx +++ b/src/components/common/Chip/Chip.style.tsx @@ -1,4 +1,4 @@ -import styled from "styled-components"; +import styled, { css } from "styled-components"; import type { ChipProps } from "components/common/Chip/Chip"; @@ -6,10 +6,9 @@ export const ChipContainer = styled.button>` padding: 8px 12px; font-size: 14px; - ${({ theme, isSelected }) => ` - background-color: ${isSelected ? theme.color.primary : theme.color.white}; - border: 1px solid ${isSelected ? theme.color.primary : theme.color.gray200}; - color: ${isSelected ? theme.color.white : theme.color.gray800}; + ${({ theme }) => css` + background-color: ${theme.color.primaryLight1}; + border: 1px solid ${theme.color.primary}; `} border-radius: ${({ theme }) => theme.borderRadius.small}; diff --git a/src/components/common/Chip/Chip.tsx b/src/components/common/Chip/Chip.tsx index 58ce13f2..0de5d159 100644 --- a/src/components/common/Chip/Chip.tsx +++ b/src/components/common/Chip/Chip.tsx @@ -1,7 +1,9 @@ +import React from "react"; + import * as S from "components/common/Chip/Chip.style"; export interface ChipProps { - children: string; + children: React.ReactNode; isSelected?: boolean; onClick?: React.MouseEventHandler; } diff --git a/src/components/common/ErrorImage/ErrorImage.style.tsx b/src/components/common/ErrorImage/ErrorImage.style.tsx index 94578717..80e2e905 100644 --- a/src/components/common/ErrorImage/ErrorImage.style.tsx +++ b/src/components/common/ErrorImage/ErrorImage.style.tsx @@ -20,5 +20,5 @@ export const ErrorImage = styled.img` export const ErrorImageText = styled.p` font-size: 1.25rem; - color: ${({ theme }) => theme.secondary}; + color: ${({ theme }) => theme.color.red}; `; diff --git a/src/components/common/ImageSkeleton/ImageSkeleton.style.ts b/src/components/common/ImageSkeleton/ImageSkeleton.style.ts new file mode 100644 index 00000000..4360f847 --- /dev/null +++ b/src/components/common/ImageSkeleton/ImageSkeleton.style.ts @@ -0,0 +1,45 @@ +import styled, { css, keyframes } from "styled-components"; + +const wave = keyframes` + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +`; + +interface ImageSkeletonProps { + $width?: string; + $height?: string; +} + +export const ImageSkeleton = styled.div` + width: ${({ $width }) => $width || "100%"}; + height: ${({ $height }) => $height || "100%"}; + + background: ${({ theme }) => css` + linear-gradient( + 90deg, + ${theme.color.gray500}, + ${theme.color.gray100}, + ${theme.color.gray500}, + ${theme.color.gray100} + ); + `}; + background-size: 200% 200%; + animation: ${wave} 4s ease infinite; + + display: flex; + justify-content: center; + align-items: center; +`; + +export const LoadingText = styled.p` + color: ${({ theme }) => theme.color.gray500}; + font-size: 1.3rem; + line-height: 1.3; +`; diff --git a/src/components/common/ImageSkeleton/ImageSkeleton.tsx b/src/components/common/ImageSkeleton/ImageSkeleton.tsx new file mode 100644 index 00000000..346346fd --- /dev/null +++ b/src/components/common/ImageSkeleton/ImageSkeleton.tsx @@ -0,0 +1,12 @@ +import * as S from "./ImageSkeleton.style"; + +interface ImageSkeletonProps { + width?: string; + height?: string; +} + +function ImageSkeleton({ width, height }: ImageSkeletonProps) { + return ; +} + +export default ImageSkeleton; diff --git a/src/components/common/Input/Input.style.tsx b/src/components/common/Input/Input.style.tsx index 750f7b5b..0f73bff7 100644 --- a/src/components/common/Input/Input.style.tsx +++ b/src/components/common/Input/Input.style.tsx @@ -43,7 +43,7 @@ export const Input = styled.input` &:focus { border: 1px solid ${({ theme }) => theme.color.redDark}; - box-shadow: 0 0 0 3px ${({ theme }) => theme.color.redLight1}; + box-shadow: 0 0 0 3px ${({ theme }) => theme.color.redLight}; } `} diff --git a/src/components/common/Modal/Modal.tsx b/src/components/common/Modal/Modal.tsx index 362c7b99..56e05c14 100644 --- a/src/components/common/Modal/Modal.tsx +++ b/src/components/common/Modal/Modal.tsx @@ -1,15 +1,22 @@ -import { PropsWithChildren, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import ReactDOM from "react-dom"; -import { CloseIcon } from "asset"; +import usePressESC from "hooks/usePressESC"; import * as S from "components/common/Modal/Modal.style"; +import CloseButton from "components/common/Modal/components/CloseButton/CloseButton"; +import Content from "components/common/Modal/components/Content/Content"; +import Footer from "components/common/Modal/components/Footer/Footer"; +import Header from "components/common/Modal/components/Header/Header"; interface ModalProps { - closeModal: () => void; + onCloseModal: () => void; } -function Modal({ children, closeModal }: PropsWithChildren) { +function Modal({ + children, + onCloseModal, +}: React.PropsWithChildren) { const [scrollOffset, setScrollOffset] = useState(0); useEffect(() => { @@ -22,18 +29,20 @@ function Modal({ children, closeModal }: PropsWithChildren) { }; }, []); + usePressESC(onCloseModal); + return ReactDOM.createPortal( - - - - - - {children} - + + {children} , document.querySelector("#app") as HTMLElement ); } export default Modal; + +Modal.ModalHeader = Header; +Modal.ModalContent = Content; +Modal.ModalFooter = Footer; +Modal.CloseButton = CloseButton; diff --git a/src/components/common/Modal/components/CloseButton/CloseButton.styled.ts b/src/components/common/Modal/components/CloseButton/CloseButton.styled.ts new file mode 100644 index 00000000..757b68b7 --- /dev/null +++ b/src/components/common/Modal/components/CloseButton/CloseButton.styled.ts @@ -0,0 +1,11 @@ +import styled, { CSSProp } from "styled-components"; + +export const CloseButton = styled.button<{ css?: CSSProp }>` + display: flex; + justify-content: center; + align-items: center; + background-color: transparent; + border: none; + + ${({ css }) => css}; +`; diff --git a/src/components/common/Modal/components/CloseButton/CloseButton.tsx b/src/components/common/Modal/components/CloseButton/CloseButton.tsx new file mode 100644 index 00000000..a449b547 --- /dev/null +++ b/src/components/common/Modal/components/CloseButton/CloseButton.tsx @@ -0,0 +1,18 @@ +import * as S from "./CloseButton.styled"; + +import { CSSProp } from "styled-components"; + +import { CloseIcon } from "asset"; + +interface CloseButtonProps { + onCloseModal: () => void; + css?: CSSProp; +} + +export default function CloseButton({ onCloseModal, css }: CloseButtonProps) { + return ( + + + + ); +} diff --git a/src/components/common/Modal/components/Content/Content.styled.ts b/src/components/common/Modal/components/Content/Content.styled.ts new file mode 100644 index 00000000..d9c31ee6 --- /dev/null +++ b/src/components/common/Modal/components/Content/Content.styled.ts @@ -0,0 +1,7 @@ +import styled, { CSSProp } from "styled-components"; + +export const ContentContainer = styled.div<{ css?: CSSProp }>` + display: flex; + flex-direction: column; + ${({ css }) => css}; +`; diff --git a/src/components/common/Modal/components/Content/Content.tsx b/src/components/common/Modal/components/Content/Content.tsx new file mode 100644 index 00000000..e7db962b --- /dev/null +++ b/src/components/common/Modal/components/Content/Content.tsx @@ -0,0 +1,10 @@ +import * as S from "./Content.styled"; + +import { CSSProp } from "styled-components"; + +export default function Content({ + children, + css, +}: React.PropsWithChildren<{ css?: CSSProp }>) { + return {children}; +} diff --git a/src/components/common/Modal/components/Footer/Footer.styled.ts b/src/components/common/Modal/components/Footer/Footer.styled.ts new file mode 100644 index 00000000..9c748a72 --- /dev/null +++ b/src/components/common/Modal/components/Footer/Footer.styled.ts @@ -0,0 +1,13 @@ +import { CSSProperties } from "react"; + +import styled, { CSSProp } from "styled-components"; + +export const FooterContainer = styled.div<{ + css?: CSSProp; + $direction: CSSProperties["flexDirection"]; +}>` + width: 100%; + display: flex; + flex-direction: ${({ $direction }) => $direction}; + ${({ css }) => css}; +`; diff --git a/src/components/common/Modal/components/Footer/Footer.tsx b/src/components/common/Modal/components/Footer/Footer.tsx new file mode 100644 index 00000000..10d3a6a6 --- /dev/null +++ b/src/components/common/Modal/components/Footer/Footer.tsx @@ -0,0 +1,18 @@ +import * as S from "./Footer.styled"; + +import { CSSProp, CSSProperties } from "styled-components"; + +export default function Footer({ + children, + css, + direction = "row", +}: React.PropsWithChildren<{ + css?: CSSProp; + direction?: CSSProperties["flexDirection"]; +}>) { + return ( + + {children} + + ); +} diff --git a/src/components/common/Modal/components/Header/Header.styled.ts b/src/components/common/Modal/components/Header/Header.styled.ts new file mode 100644 index 00000000..b8db7109 --- /dev/null +++ b/src/components/common/Modal/components/Header/Header.styled.ts @@ -0,0 +1,8 @@ +import styled, { CSSProp } from "styled-components"; + +export const HeaderContainer = styled.div<{ css?: CSSProp }>` + display: flex; + justify-content: space-between; + align-items: center; + ${({ css }) => css}; +`; diff --git a/src/components/common/Modal/components/Header/Header.tsx b/src/components/common/Modal/components/Header/Header.tsx new file mode 100644 index 00000000..67ce9033 --- /dev/null +++ b/src/components/common/Modal/components/Header/Header.tsx @@ -0,0 +1,10 @@ +import * as S from "./Header.styled"; + +import { CSSProp } from "styled-components"; + +export default function Header({ + children, + css, +}: React.PropsWithChildren<{ css?: CSSProp }>) { + return {children}; +} diff --git a/src/components/common/SearchBar/SearchBar.style.tsx b/src/components/common/SearchBar/SearchBar.style.tsx index 0c166cd4..1c216fef 100644 --- a/src/components/common/SearchBar/SearchBar.style.tsx +++ b/src/components/common/SearchBar/SearchBar.style.tsx @@ -1,6 +1,10 @@ import styled, { css } from "styled-components"; -export const Container = styled.form` +export const Container = styled.div` + position: relative; +`; + +export const FormContainer = styled.form` width: 100%; display: flex; `; @@ -17,18 +21,44 @@ export const InputContainer = styled.div` } `; -export const inputStyle = css` +export const inputStyle = (isDropdownOpen: boolean) => css` width: 100%; + height: 5rem; + + background-color: ${({ theme }) => theme.color.primaryLight4}; + + border: 1px solid ${({ theme }) => theme.color.primaryLight2}; + border-right: 0px; border-top-right-radius: 0; border-bottom-right-radius: 0; + + ${isDropdownOpen && + css` + border-bottom-left-radius: 0; + `} `; -export const buttonStyle = css` - width: 4.85rem; - height: 4.85rem; +export const buttonStyle = (isDropdownOpen: boolean) => css` + width: 5rem; + height: 5rem; + + background-color: ${({ theme }) => theme.color.primaryLight4}; + + border: 1px solid ${({ theme }) => theme.color.primaryLight2}; + border-left: 0px; border-top-left-radius: 0; border-bottom-left-radius: 0; + ${isDropdownOpen && + css` + border-bottom-right-radius: 0; + `} + + &:hover:enabled { + background-color: ${({ theme }) => theme.color.primaryLight3}; + border-color: ${({ theme }) => theme.color.primaryLight2}; + } + &:focus { box-shadow: none; outline: 0; diff --git a/src/components/common/SearchBar/SearchBar.tsx b/src/components/common/SearchBar/SearchBar.tsx index 37250d92..34cfd0f1 100644 --- a/src/components/common/SearchBar/SearchBar.tsx +++ b/src/components/common/SearchBar/SearchBar.tsx @@ -1,3 +1,4 @@ +import AutoComplete from "../AutoComplete/AutoComplete"; import Button from "../Button/Button"; import Input from "../Input/Input"; import React, { useEffect, useState } from "react"; @@ -7,6 +8,10 @@ import ROUTES, { PATHNAME } from "constants/routes"; import { SearchIcon } from "asset"; +import useAutoComplete from "hooks/useAutoComplete"; +import useBackdropClick from "hooks/useBackdropClick"; +import useFocusTrap from "hooks/useFocusTrap"; + import * as S from "components/common/SearchBar/SearchBar.style"; interface SearchBarProps { @@ -16,13 +21,27 @@ interface SearchBarProps { function SearchBar({ closeSearchBar }: SearchBarProps) { const [keyword, setKeyword] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const closeDropdown = () => setIsDropdownOpen(false); + + const { autoCompleteList, handleImmediateKeyword, handleDebouncedKeyword } = + useAutoComplete(keyword); + + const searchBarRef = useFocusTrap(isDropdownOpen, autoCompleteList.length); + useBackdropClick(searchBarRef, closeDropdown); + const location = useLocation(); const navigate = useNavigate(); + const handleKeyword = (keyword: string) => { + setKeyword(keyword); + }; + const handleSearchInput: React.ChangeEventHandler = ({ target: { value }, }) => { setKeyword(value); + handleDebouncedKeyword(value); }; const handleSearchButtonClick: React.FormEventHandler = ( @@ -32,9 +51,8 @@ function SearchBar({ closeSearchBar }: SearchBarProps) { if (!keyword) return; navigate(`${PATHNAME.SEARCH}?name=${keyword}`); - if (closeSearchBar !== undefined) { - closeSearchBar(); - } + if (closeSearchBar !== undefined) closeSearchBar(); + closeDropdown(); }; useEffect(() => { @@ -44,20 +62,30 @@ function SearchBar({ closeSearchBar }: SearchBarProps) { }, [location]); return ( - - - + + setIsDropdownOpen(true)}> + handleImmediateKeyword(keyword)} + onChange={handleSearchInput} + /> + + + + {isDropdownOpen && autoCompleteList.length > 0 && ( + - - + )} ); } diff --git a/src/components/common/Select/Select.style.tsx b/src/components/common/Select/Select.style.tsx index 0c2fbec5..92dbd28f 100644 --- a/src/components/common/Select/Select.style.tsx +++ b/src/components/common/Select/Select.style.tsx @@ -25,7 +25,7 @@ export const Select = styled.select>` &:focus { border: 1px solid ${({ theme }) => theme.color.redDark}; - box-shadow: 0 0 0 3px ${({ theme }) => theme.color.redLight1}; + box-shadow: 0 0 0 3px ${({ theme }) => theme.color.redLight}; } `} ${({ $size = "medium" }) => getSizeStyling($size)} diff --git a/src/components/common/Spinner/Spinner.style.tsx b/src/components/common/Spinner/Spinner.style.tsx index 182052a7..eaf85b7b 100644 --- a/src/components/common/Spinner/Spinner.style.tsx +++ b/src/components/common/Spinner/Spinner.style.tsx @@ -1,15 +1,27 @@ -import styled from "styled-components"; +import { SpinnerPosition } from "./Spinner"; -export const SpinnerContainer = styled.div` +import styled, { css } from "styled-components"; + +export const SpinnerContainer = styled.div<{ $position: SpinnerPosition }>` display: flex; justify-content: center; align-items: center; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; + ${({ $position }) => + $position === "static" && + css` + padding: 2rem 0; + `} + + ${({ $position }) => + $position === "fixed" && + css` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + `} `; export const SpinDiv = styled.div` diff --git a/src/components/common/Spinner/Spinner.tsx b/src/components/common/Spinner/Spinner.tsx index 15ffd924..0bdbfc78 100644 --- a/src/components/common/Spinner/Spinner.tsx +++ b/src/components/common/Spinner/Spinner.tsx @@ -1,8 +1,14 @@ import * as S from "components/common/Spinner/Spinner.style"; -function Spinner() { +export type SpinnerPosition = "static" | "fixed"; + +interface SpinnerProps { + position?: SpinnerPosition; +} + +function Spinner({ position = "fixed" }: SpinnerProps) { return ( - + ); diff --git a/src/components/common/StoreList/StoreList.tsx b/src/components/common/StoreList/StoreList.tsx index 303ebe4e..6540586f 100644 --- a/src/components/common/StoreList/StoreList.tsx +++ b/src/components/common/StoreList/StoreList.tsx @@ -1,28 +1,22 @@ import Divider from "../Divider/Divider"; import { Fragment } from "react"; -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[] | BookmarkStore[]; +interface StoreListProps { + stores: T[]; + renderListItem: (store: T) => JSX.Element; } -function StoreList({ stores }: StoreListProps) { +function StoreList({ + stores, + renderListItem, +}: StoreListProps) { return ( {stores?.map((store) => ( - + {renderListItem(store)} ))} diff --git "a/src/components/common/StoreListItem/\bStoreListItemWithoutHeart.tsx" "b/src/components/common/StoreListItem/\bStoreListItemWithoutHeart.tsx" new file mode 100644 index 00000000..2a739f45 --- /dev/null +++ "b/src/components/common/StoreListItem/\bStoreListItemWithoutHeart.tsx" @@ -0,0 +1,55 @@ +import Text from "../Text/Text"; +import { useContext } from "react"; +import { useNavigate } from "react-router-dom"; +import type { BookmarkStore } from "types/common"; +import { getRandomEmptyReviewMessage } from "util/randomUtils"; + +import { PATHNAME } from "constants/routes"; + +import { campusContext } from "context/CampusContextProvider"; + +import Star from "components/common/Star/Star"; +import * as S from "components/common/StoreListItem/StoreListItem.style"; + +function StoreListItemWithoutHeart({ + id, + name, + distance, + rating, + reviewCount, + thumbnailUrl, +}: Omit) { + const navigate = useNavigate(); + const campusName = useContext(campusContext); + + return ( + { + navigate(`${PATHNAME.STORE_DETAIL}/${id}`); + }} + > + + + {name} + + {reviewCount !== 0 ? ( + <> + + {rating.toFixed(1)} + + ) : ( + <> + + {getRandomEmptyReviewMessage()} + + )} + + + {campusName} 캠퍼스 기준 도보 {distance}분 + + + + ); +} + +export default StoreListItemWithoutHeart; diff --git a/src/components/common/StoreListItem/StoreListItem.tsx b/src/components/common/StoreListItem/StoreListItemWithHeart.tsx similarity index 82% rename from src/components/common/StoreListItem/StoreListItem.tsx rename to src/components/common/StoreListItem/StoreListItemWithHeart.tsx index 5f872f84..a91ad142 100644 --- a/src/components/common/StoreListItem/StoreListItem.tsx +++ b/src/components/common/StoreListItem/StoreListItemWithHeart.tsx @@ -2,7 +2,7 @@ import Heart from "../Heart/Heart"; import Text from "../Text/Text"; import { useContext } from "react"; import { useNavigate } from "react-router-dom"; -import type { Store } from "types/common"; +import type { StoreItemWithHeart } from "types/common"; import { getRandomEmptyReviewMessage } from "util/randomUtils"; import { ACCESS_TOKEN } from "constants/api"; @@ -15,15 +15,7 @@ import { useMarked } from "hooks/useMarked"; import Star from "components/common/Star/Star"; import * as S from "components/common/StoreListItem/StoreListItem.style"; -interface StoreListItemProps - extends Pick< - Store, - "id" | "name" | "distance" | "rating" | "reviewCount" | "liked" - > { - thumbnailUrl: string; -} - -function StoreListItem({ +function StoreListItemWithHeart({ id, thumbnailUrl, name, @@ -31,8 +23,8 @@ function StoreListItem({ rating, reviewCount, liked, -}: StoreListItemProps) { - const { marked, handleMarked } = useMarked(id, liked); +}: StoreItemWithHeart) { + const { marked, handleMarked } = useMarked(liked); const navigate = useNavigate(); const campusName = useContext(campusContext); @@ -64,7 +56,7 @@ function StoreListItem({ {campusName} 캠퍼스 기준 도보 {distance}분 - + handleMarked(event, id)}> {accessToken && marked ? ( ) : ( @@ -75,4 +67,4 @@ function StoreListItem({ ); } -export default StoreListItem; +export default StoreListItemWithHeart; diff --git a/src/components/common/Textarea/Textarea.style.tsx b/src/components/common/Textarea/Textarea.style.tsx index e725cf15..1423f98b 100644 --- a/src/components/common/Textarea/Textarea.style.tsx +++ b/src/components/common/Textarea/Textarea.style.tsx @@ -27,7 +27,7 @@ export const Textarea = styled.textarea` &:focus { border: 1px solid ${({ theme }) => theme.color.redDark}; - box-shadow: 0 0 0 3px ${({ theme }) => theme.color.redLight1}; + box-shadow: 0 0 0 3px ${({ theme }) => theme.color.redLight}; } `} diff --git a/src/components/common/Toast/Toast.styled.ts b/src/components/common/Toast/Toast.styled.ts new file mode 100644 index 00000000..08ddad9e --- /dev/null +++ b/src/components/common/Toast/Toast.styled.ts @@ -0,0 +1,52 @@ +import styled from "styled-components"; +import { keyframes, css } from "styled-components"; + +import { ToastStatus } from "components/common/Toast/Toast.type"; + +const fadeIn = keyframes` + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +`; + +const fadeOut = keyframes` + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +`; + +const getBackgroundColor = ($type: ToastStatus) => { + switch ($type) { + case "active": + return css` + background-color: ${({ theme }) => theme.color.green}; + `; + case "danger": + return css` + background-color: ${({ theme }) => theme.color.red}; + `; + } +}; + +export const ToastContainer = styled.div<{ + $isOpen: boolean; + $type: ToastStatus; +}>` + ${({ $type }) => getBackgroundColor($type)}; + color: ${({ theme }) => theme.color.white}; + width: 48rem; + height: 5rem; + position: fixed; + top: 0; + display: flex; + justify-content: center; + align-items: center; + z-index: ${({ theme }) => theme.zIndex.toast}; + animation: ${({ $isOpen }) => ($isOpen ? fadeIn : fadeOut)} 0.7s ease-out; +`; diff --git a/src/components/common/Toast/Toast.tsx b/src/components/common/Toast/Toast.tsx new file mode 100644 index 00000000..a583d94d --- /dev/null +++ b/src/components/common/Toast/Toast.tsx @@ -0,0 +1,19 @@ +import * as S from "./Toast.styled"; + +import { ToastStatus } from "components/common/Toast/Toast.type"; + +interface ToastProps { + message: string; + isOpen: boolean; + type: ToastStatus; +} + +const Toast = ({ message, isOpen, type }: ToastProps) => { + return ( + + {message} + + ); +}; + +export default Toast; diff --git a/src/components/common/Toast/Toast.type.ts b/src/components/common/Toast/Toast.type.ts new file mode 100644 index 00000000..87e9c098 --- /dev/null +++ b/src/components/common/Toast/Toast.type.ts @@ -0,0 +1 @@ +export type ToastStatus = "danger" | "active"; diff --git a/src/components/common/Toast/provider/ToastProvider.constant.ts b/src/components/common/Toast/provider/ToastProvider.constant.ts new file mode 100644 index 00000000..d9cfb01f --- /dev/null +++ b/src/components/common/Toast/provider/ToastProvider.constant.ts @@ -0,0 +1,2 @@ +export const ANIMATION_DURATION = 500; +export const TOAST_DISPLAY_DURATION = 1000; diff --git a/src/components/common/Toast/provider/ToastProvider.tsx b/src/components/common/Toast/provider/ToastProvider.tsx new file mode 100644 index 00000000..c95ac086 --- /dev/null +++ b/src/components/common/Toast/provider/ToastProvider.tsx @@ -0,0 +1,68 @@ +import Toast from "../Toast"; +import { + ANIMATION_DURATION, + TOAST_DISPLAY_DURATION, +} from "./ToastProvider.constant"; +import { + createContext, + useCallback, + useContext, + useRef, + useState, +} from "react"; + +import { ToastStatus } from "components/common/Toast/Toast.type"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const ToastContext = createContext( + (message: string, type?: ToastStatus) => {} +); + +export const useToastContext = () => { + const value = useContext(ToastContext); + + if (!value) throw new Error("ToastProvider 내부에서 사용해야 합니다."); + + return value; +}; + +const ToastProvider: React.FC> = ({ children }) => { + const [message, setMessage] = useState(""); + const [status, setStatus] = useState("danger"); + const [isOpenToast, setIsOpenToast] = useState(false); + const [isRemove, setIsRemove] = useState(true); + + const toastTimer = useRef>(); + + const showToast = useCallback((message: string, type?: ToastStatus) => { + setIsRemove(false); + setIsOpenToast(true); + setMessage(message); + setStatus(type ?? "danger"); + + if (toastTimer.current) { + clearTimeout(toastTimer.current); + } + + const timer = setTimeout(() => { + setIsOpenToast(false); + setTimeout(() => { + setMessage(""); + setIsRemove(true); + }, ANIMATION_DURATION); + }, TOAST_DISPLAY_DURATION); + + toastTimer.current = timer; + }, []); + + return ( + + {children} + {!isRemove && ( + + )} + + ); +}; + +export default ToastProvider; diff --git a/src/components/layout/Header/CampusSelectModal/CampusSelectModal.styled.ts b/src/components/layout/Header/CampusSelectModal/CampusSelectModal.styled.ts new file mode 100644 index 00000000..b8346906 --- /dev/null +++ b/src/components/layout/Header/CampusSelectModal/CampusSelectModal.styled.ts @@ -0,0 +1,15 @@ +import styled, { css } from "styled-components"; + +export const ContentContainer = styled.div` + display: flex; + flex-direction: column; +`; + +export const ButtonContainerStyling = css` + display: flex; + gap: ${({ theme }) => theme.spacer.spacing1}; +`; + +export const ModalContentStyling = css` + padding: 3rem 0; +`; diff --git a/src/components/layout/Header/CampusSelectModal/CampusSelectModal.tsx b/src/components/layout/Header/CampusSelectModal/CampusSelectModal.tsx new file mode 100644 index 00000000..55bc4bdd --- /dev/null +++ b/src/components/layout/Header/CampusSelectModal/CampusSelectModal.tsx @@ -0,0 +1,50 @@ +import * as S from "./CampusSelectModal.styled"; +import { useContext } from "react"; +import { useNavigate } from "react-router-dom"; +import { Campus } from "types/common"; + +import { getOtherCampus } from "constants/campus"; +import { MESSAGES } from "constants/messages"; +import { PATHNAME } from "constants/routes"; + +import { campusContext, setCampusContext } from "context/CampusContextProvider"; + +import { Button } from "components/common/Button/Button.style"; +import { Heading } from "components/common/Heading/Heading.style"; +import Modal from "components/common/Modal/Modal"; + +interface CampusSelectModalProps { + onCloseModal: () => void; +} + +export default function CampusSelectModal({ + onCloseModal, +}: CampusSelectModalProps) { + const campus = useContext(campusContext) as Campus; + const otherCampus = getOtherCampus(campus as Campus); + const setCampus = useContext(setCampusContext); + const navigate = useNavigate(); + + const handleConfirm = () => { + setCampus(otherCampus); + onCloseModal(); + navigate(PATHNAME.HOME); + }; + return ( + + + 캠퍼스를 변경하시겠습니까? + + + +

{MESSAGES.CAMPUS_CHANGE_CONFIRM(campus, otherCampus)}

+
+ + + + +
+ ); +} diff --git a/src/components/layout/Header/Header.style.tsx b/src/components/layout/Header/Header.style.tsx index 3ae2b2a4..e7889bd0 100644 --- a/src/components/layout/Header/Header.style.tsx +++ b/src/components/layout/Header/Header.style.tsx @@ -31,6 +31,12 @@ export const TopWrapper = styled.div` justify-content: space-between; `; +export const LeftWrapper = styled.div` + display: flex; + align-items: center; + gap: 0.8rem; +`; + export const PageName = styled.h1` width: fit-content; @@ -41,8 +47,30 @@ export const PageName = styled.h1` font-size: 1.25rem; `; +export const LogoWrapper = styled.div` + display: flex; + gap: 0.8rem; +`; + export const LogoImage = styled(Image)` - width: 12rem; + width: 3rem; +`; + +export const LogoText = styled(Image)` + width: 8.3rem; + height: 3rem; +`; + +export const BackButton = styled.button` + border: none; + background-color: transparent; + width: 3rem; + height: 3rem; + + & > svg { + width: 2rem; + height: 2rem; + } `; export const Campus = styled(Text).attrs({ as: "span" })` @@ -59,7 +87,7 @@ export const RightWrapper = styled.div` `; export const Profile = styled.div` - background-color: ${({ theme }) => theme.color.whitre}; + background-color: ${({ theme }) => theme.color.white}; border-radius: 50%; width: 1.875rem; height: 1.875rem; diff --git a/src/components/layout/Header/Header.tsx b/src/components/layout/Header/Header.tsx index 420bb903..f9dd5774 100644 --- a/src/components/layout/Header/Header.tsx +++ b/src/components/layout/Header/Header.tsx @@ -1,34 +1,57 @@ import MenuDrawer from "../MenuDrawer/MenuDrawer"; import { useContext, useEffect, useState } from "react"; import { GiHamburgerMenu } from "react-icons/gi"; -import { Link, useLocation } from "react-router-dom"; +import { MdArrowBackIos } from "react-icons/md"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import { PATHNAME } from "constants/routes"; -import logoImg from "asset/logo-light.svg"; +import logoImg from "asset/logo-light-image.svg"; +import logoText from "asset/logo-light-text.svg"; import { campusContext } from "context/CampusContextProvider"; import { LoginContext } from "context/LoginContextProvider"; import SearchBar from "components/common/SearchBar/SearchBar"; +import CampusSelectModal from "components/layout/Header/CampusSelectModal/CampusSelectModal"; import * as S from "components/layout/Header/Header.style"; +import LogoutModal from "components/layout/Header/LogoutModal/LogoutModal"; function Header() { const isLoggedIn = useContext(LoginContext); const campus = useContext(campusContext); const [isMenuOpen, setMenuOpen] = useState(false); + const [isSelectModalOpen, setIsSelectModalOpen] = useState(false); + const [isLogoutModalOpen, setIsLogoutModalOpen] = useState(false); + + const openMenu = () => { + setMenuOpen(true); + }; const closeMenu = () => { setMenuOpen(false); }; - const location = useLocation(); + const openSelectModal = () => setIsSelectModalOpen(true); - const openMenu = () => { - setMenuOpen(true); + const closeSelectModal = () => setIsSelectModalOpen(false); + + const openLogoutModal = () => setIsLogoutModalOpen(true); + + const closeLogoutModal = () => setIsLogoutModalOpen(false); + + const navigate = useNavigate(); + const goBack = () => { + navigate(-1); }; + const location = useLocation(); + const isMainPage = location.pathname === PATHNAME.HOME; + const isCategoryDetailPage = location.pathname.startsWith( + PATHNAME.CATEGORY_DETAIL + ); + const handleIconClick = () => { window.scrollTo({ top: 0, @@ -43,22 +66,43 @@ function Header() { return ( - - - - {campus && in {campus}} - - + + {!isMainPage && ( + + + + )} + + + + {isMainPage && ( + + )} + + + {campus && in {campus}} + + + {isMenuOpen && ( - + + )} + {isSelectModalOpen && ( + )} + {isLogoutModalOpen && } - + {(isMainPage || isCategoryDetailPage) && } ); } diff --git a/src/components/layout/Header/LogoutModal/LogoutModal.styled.ts b/src/components/layout/Header/LogoutModal/LogoutModal.styled.ts new file mode 100644 index 00000000..b8346906 --- /dev/null +++ b/src/components/layout/Header/LogoutModal/LogoutModal.styled.ts @@ -0,0 +1,15 @@ +import styled, { css } from "styled-components"; + +export const ContentContainer = styled.div` + display: flex; + flex-direction: column; +`; + +export const ButtonContainerStyling = css` + display: flex; + gap: ${({ theme }) => theme.spacer.spacing1}; +`; + +export const ModalContentStyling = css` + padding: 3rem 0; +`; diff --git a/src/components/layout/Header/LogoutModal/LogoutModal.tsx b/src/components/layout/Header/LogoutModal/LogoutModal.tsx new file mode 100644 index 00000000..7f53eaec --- /dev/null +++ b/src/components/layout/Header/LogoutModal/LogoutModal.tsx @@ -0,0 +1,47 @@ +import * as S from "./LogoutModal.styled"; +import { useNavigate } from "react-router-dom"; + +import { MESSAGES } from "constants/messages"; +import { PATHNAME } from "constants/routes"; + +import useLogin from "hooks/useLogin"; + +import Button from "components/common/Button/Button"; +import Heading from "components/common/Heading/Heading"; +import Modal from "components/common/Modal/Modal"; +import { useToastContext } from "components/common/Toast/provider/ToastProvider"; + +interface LogoutModalProps { + onCloseModal: () => void; +} + +export default function LogoutModal({ onCloseModal }: LogoutModalProps) { + const navigate = useNavigate(); + const { logout } = useLogin(); + + const showToast = useToastContext(); + + const handleLogout = () => { + logout(); + showToast(MESSAGES.LOGOUT_COMPLETE, "active"); + onCloseModal(); + navigate(PATHNAME.HOME); + }; + return ( + + + {MESSAGES.LOGOUT_CONFIRM} + + + +

로그아웃 시 다시 로그인 해야 합니다.

+
+ + + + +
+ ); +} diff --git a/src/components/layout/MenuDrawer/MenuDrawer.tsx b/src/components/layout/MenuDrawer/MenuDrawer.tsx index 07ad6566..3e2394cf 100644 --- a/src/components/layout/MenuDrawer/MenuDrawer.tsx +++ b/src/components/layout/MenuDrawer/MenuDrawer.tsx @@ -1,58 +1,36 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { useContext, useEffect } from "react"; +import { 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"; -import { getOtherCampus } from "constants/campus"; -import { MESSAGES } from "constants/messages"; import { PATHNAME } from "constants/routes"; -import { campusContext, setCampusContext } from "context/CampusContextProvider"; - -import useLogin from "hooks/useLogin"; - import Button from "components/common/Button/Button"; import Text from "components/common/Text/Text"; import * as S from "components/layout/MenuDrawer/MenuDrawer.style"; interface MenuDrawerProps { - closeMenu: () => void; + onCloseMenu: () => void; + onOpenCampusSelectModal: () => void; + onOpenLogoutModal: () => void; isLoggedIn: boolean; } -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(); - +function MenuDrawer({ + isLoggedIn, + onCloseMenu, + onOpenCampusSelectModal, + onOpenLogoutModal, +}: MenuDrawerProps) { const handleCampusChangeRequest = () => { - if ( - !window.confirm( - MESSAGES.CAMPUS_CHANGE_CONFIRM(campus as Campus, otherCampus) - ) - ) { - return; - } - setCampus(otherCampus); - closeMenu(); - navigate(PATHNAME.HOME); + onCloseMenu(); + onOpenCampusSelectModal(); }; const handleLogout = () => { - if (!window.confirm(MESSAGES.LOGOUT_CONFIRM)) { - return; - } - - logout(); - closeMenu(); - window.alert(MESSAGES.LOGOUT_COMPLETE); - navigate(PATHNAME.HOME); + onCloseMenu(); + onOpenLogoutModal(); }; useEffect(() => { @@ -65,7 +43,7 @@ function MenuDrawer({ closeMenu, isLoggedIn }: MenuDrawerProps) { return ReactDOM.createPortal( - + {isLoggedIn ? ( <> diff --git a/src/components/pages/CampusSelectPage/CampusSelectPage.tsx b/src/components/pages/CampusSelectPage/CampusSelectPage.tsx index 7b16c2a3..0f5daf31 100644 --- a/src/components/pages/CampusSelectPage/CampusSelectPage.tsx +++ b/src/components/pages/CampusSelectPage/CampusSelectPage.tsx @@ -5,13 +5,14 @@ import { Campus } from "types/common"; import { CAMPUS } from "constants/campus"; import { PATHNAME } from "constants/routes"; +import { LogoLight } from "asset"; + import { setCampusContext } from "context/CampusContextProvider"; import Button from "components/common/Button/Button"; import Heading from "components/common/Heading/Heading"; import * as S from "components/pages/CampusSelectPage/CampusSelectPage.style"; -import { LogoLight } from "asset"; function CampusSelectPage() { const setCampus = useContext(setCampusContext); diff --git a/src/components/pages/CategoryDetailPage/CategoryDetailPage.style.tsx b/src/components/pages/CategoryDetailPage/CategoryDetailPage.style.tsx index e53a9058..4224c414 100644 --- a/src/components/pages/CategoryDetailPage/CategoryDetailPage.style.tsx +++ b/src/components/pages/CategoryDetailPage/CategoryDetailPage.style.tsx @@ -12,3 +12,25 @@ export const ChipContainer = styled.div` display: flex; gap: ${({ theme }) => theme.spacer.spacing3}; `; + +export const ChipContent = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +export const FilterOptionContainer = styled.section` + max-height: 600px; + overflow-y: auto; + display: flex; + flex-direction: column; + row-gap: 24px; +`; + +export const FilterOption = styled.div` + width: 100%; + display: flex; + justify-content: space-between; + + cursor: pointer; +`; diff --git a/src/components/pages/CategoryDetailPage/CategoryDetailPage.tsx b/src/components/pages/CategoryDetailPage/CategoryDetailPage.tsx index b6bc4974..bb52aa77 100644 --- a/src/components/pages/CategoryDetailPage/CategoryDetailPage.tsx +++ b/src/components/pages/CategoryDetailPage/CategoryDetailPage.tsx @@ -1,22 +1,33 @@ -/* eslint-disable react-hooks/exhaustive-deps */ import { useEffect, useState } from "react"; import { useContext } from "react"; -import { MdArrowBackIos } from "react-icons/md"; +import { TbArrowsUpDown } from "react-icons/tb"; import { useInfiniteQuery } from "react-query"; -import { Navigate, useNavigate, useParams } from "react-router-dom"; -import { Campus, CategoryId, Store } from "types/common"; +import { Navigate, useParams } from "react-router-dom"; -import { NETWORK, SIZE, FILTERS } from "constants/api"; +import { Campus, CategoryId, StoreItemWithHeart } from "types/common"; + +import { + NETWORK, + SIZE, + FilterOption, + STORE_FILTER_OPTIONS, + entries, +} from "constants/api"; import { getCampusId } from "constants/campus"; import { categories } from "constants/categories"; import { MESSAGES } from "constants/messages"; +import { QUERY_KEY } from "constants/queryKey"; import { PATHNAME } from "constants/routes"; +import { Check } from "asset"; + import { campusContext } from "context/CampusContextProvider"; import getNextPageParam from "api/getNextPageParam"; import fetchStoreList from "api/store/fetchStoreList"; +import BottomSheet from "components/common/BottomSheet/BottomSheet"; +import Button from "components/common/Button/Button"; import Chip from "components/common/Chip/Chip"; import ErrorImage from "components/common/ErrorImage/ErrorImage"; import ErrorText from "components/common/ErrorText/ErrorText"; @@ -24,17 +35,23 @@ import InfiniteScroll from "components/common/InfiniteScroll/InfiniteScroll"; import SectionHeader from "components/common/SectionHeader/SectionHeader"; import Spinner from "components/common/Spinner/Spinner"; import StoreList from "components/common/StoreList/StoreList"; +import StoreListItemWithHeart from "components/common/StoreListItem/StoreListItemWithHeart"; +import Text from "components/common/Text/Text"; +import { useToastContext } from "components/common/Toast/provider/ToastProvider"; import * as S from "components/pages/CategoryDetailPage/CategoryDetailPage.style"; function CategoryDetailPage() { - const navigate = useNavigate(); + const [isFilteringBottomSheetOpen, setIsFilteringBottomSheetOpen] = + useState(false); + const openSheet = () => setIsFilteringBottomSheetOpen(true); + const closeSheet = () => setIsFilteringBottomSheetOpen(false); const campusName = useContext(campusContext); const campusId = getCampusId(campusName as Campus); const { categoryId } = useParams(); - const [filter, setFilter] = useState(null); + const [filter, setFilter] = useState("basic"); const fetchParams = { size: SIZE.LIST_ITEM, filter, campusId, categoryId }; @@ -46,23 +63,25 @@ function CategoryDetailPage() { fetchNextPage, isFetching, refetch, - } = useInfiniteQuery(["categoryStore", fetchParams], fetchStoreList, { + } = useInfiniteQuery(QUERY_KEY.categoryStore(fetchParams), fetchStoreList, { getNextPageParam, retry: NETWORK.RETRY_COUNT, + refetchOnWindowFocus: false, }); const loadMoreStores = () => { fetchNextPage(); }; - const handleClickFilterChip = (index: number) => () => { - setFilter((prev) => - prev === FILTERS[index].order ? "" : FILTERS[index].order - ); + const handleClickFilterOption = (option: FilterOption) => { + if (filter === option) return; + setFilter(option); }; + const currentOption = STORE_FILTER_OPTIONS[filter]; + const categoryStores = - data?.pages.reduce( + data?.pages.reduce( (stores, page) => [...stores, ...page.restaurants], [] ) || []; @@ -75,8 +94,10 @@ function CategoryDetailPage() { return categoryId in categories; }; + const showToast = useToastContext(); + if (!categoryId || !Number(categoryId) || !isValidCategoryId(categoryId)) { - window.alert(MESSAGES.WRONG_PATH); + showToast(MESSAGES.WRONG_PATH); return ; } @@ -84,24 +105,14 @@ function CategoryDetailPage() { return ( - } - onClick={() => { - navigate(-1); - }} - > - {categoryName || "%ERROR%"} - + {categoryName || "%ERROR%"} - {FILTERS.map((chip, index) => ( - - {chip.text} - - ))} + + + + {currentOption} + + {(isLoading || isFetching) && } @@ -109,11 +120,47 @@ function CategoryDetailPage() { )} {categoryStores.length ? ( - + + stores={categoryStores} + renderListItem={(store) => } + /> ) : ( 가게 정보가 없습니다. )} + {isFilteringBottomSheetOpen && ( + + + {entries(STORE_FILTER_OPTIONS).map(([key, value]) => { + return ( + { + handleClickFilterOption(key); + closeSheet(); + }} + key={key} + > + {value} + {filter === key && } + + ); + })} + + + + )} ); } diff --git a/src/components/pages/CategoryPage/CategoryItem/CategoryItem.tsx b/src/components/pages/CategoryPage/CategoryItem/CategoryItem.tsx index ce006a2e..b8ca2070 100644 --- a/src/components/pages/CategoryPage/CategoryItem/CategoryItem.tsx +++ b/src/components/pages/CategoryPage/CategoryItem/CategoryItem.tsx @@ -1,8 +1,10 @@ -import Text from "components/common/Text/Text"; +import { Category } from "types/common"; + import { CATEGORY_ICONS } from "constants/categories"; +import Text from "components/common/Text/Text"; + import * as S from "components/pages/CategoryPage/CategoryItem/CategoryItem.style"; -import { Category } from "types/common"; interface CategoryItemProps { buttonText: string; diff --git a/src/components/pages/CategoryPage/CategoryPage.tsx b/src/components/pages/CategoryPage/CategoryPage.tsx index 9d42bf59..17a118cb 100644 --- a/src/components/pages/CategoryPage/CategoryPage.tsx +++ b/src/components/pages/CategoryPage/CategoryPage.tsx @@ -1,10 +1,11 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { useContext, useEffect } from "react"; import { useQuery } from "react-query"; -import { Campus } from "types/common"; +import { Campus, StoreItemWithHeart } from "types/common"; import { NETWORK, SIZE } from "constants/api"; import { getCampusId } from "constants/campus"; +import { QUERY_KEY } from "constants/queryKey"; import { campusContext } from "context/CampusContextProvider"; @@ -14,6 +15,7 @@ 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 StoreListItemWithHeart from "components/common/StoreListItem/StoreListItemWithHeart"; import Category from "components/pages/CategoryPage/Category/Category"; import * as S from "components/pages/CategoryPage/CategoryPage.style"; @@ -24,7 +26,7 @@ function CategoryPage() { const campusId = getCampusId(campusName as Campus); const { data, isLoading, isError, error, refetch } = useQuery( - "randomStore", + QUERY_KEY.randomStore, () => fetchRandomStoreList(campusId, SIZE.RANDOM_ITEM), { retry: NETWORK.RETRY_COUNT, @@ -52,7 +54,10 @@ function CategoryPage() { {isError && error instanceof Error && ( )} - + + stores={data ?? []} + renderListItem={(store) => } + /> ); diff --git a/src/components/pages/CategoryPage/RandomRoulette/RandomRoulette.style.tsx b/src/components/pages/CategoryPage/RandomRoulette/RandomRoulette.style.ts similarity index 100% rename from src/components/pages/CategoryPage/RandomRoulette/RandomRoulette.style.tsx rename to src/components/pages/CategoryPage/RandomRoulette/RandomRoulette.style.ts diff --git a/src/components/pages/CategoryPage/RandomRoulette/RandomRoulette.tsx b/src/components/pages/CategoryPage/RandomRoulette/RandomRoulette.tsx index 0d9fa5f0..a4e9bfb3 100644 --- a/src/components/pages/CategoryPage/RandomRoulette/RandomRoulette.tsx +++ b/src/components/pages/CategoryPage/RandomRoulette/RandomRoulette.tsx @@ -1,51 +1,34 @@ import { useEffect } from "react"; -import { useQuery } from "react-query"; +import { CampusId } from "types/common"; -import { NETWORK, SIZE } from "constants/api"; +import { ROULETTE_BUTTON_TEXT } from "constants/roulette"; import useRandomPick from "hooks/useRandomPick"; -import fetchRandomStoreList from "api/store/fetchRandomStoreList"; - import Button from "components/common/Button/Button"; import ErrorImage from "components/common/ErrorImage/ErrorImage"; import Spinner from "components/common/Spinner/Spinner"; -import StoreListItem from "components/common/StoreListItem/StoreListItem"; +import StoreListItemWithHeart from "components/common/StoreListItem/StoreListItemWithHeart"; import Text from "components/common/Text/Text"; import * as S from "components/pages/CategoryPage/RandomRoulette/RandomRoulette.style"; type Props = { - campusId: 1 | 2; + campusId: CampusId; }; function RandomRoulette({ campusId }: Props) { const { - data: stores, isLoading, + isRefetching, isError, error, - refetch, - } = useQuery( - "randomStoreRoulette", - () => fetchRandomStoreList(campusId, SIZE.RANDOM_ITEM), - { - retry: NETWORK.RETRY_COUNT, - refetchOnWindowFocus: false, - } - ); - - const { state: { rouletteBoard, isResultOpen, result, triggerAnimation }, handleRunClick, - openResult, - reset, - } = useRandomPick(stores || []); - - const resetHard = () => { - refetch(); - reset(); - }; + showResult, + resetHard, + refetchAndStartSpin, + } = useRandomPick(campusId); useEffect(() => { resetHard(); @@ -60,7 +43,7 @@ function RandomRoulette({ campusId }: Props) { 오늘은 - + {rouletteBoard.map((store, index) => ( {store} ))} @@ -73,31 +56,29 @@ function RandomRoulette({ campusId }: Props) { variant="primary" size="small" onClick={handleRunClick} - disabled={result !== undefined} + disabled={triggerAnimation || isRefetching} > - 룰렛 Go! + {triggerAnimation + ? ROULETTE_BUTTON_TEXT.SPINNING + : ROULETTE_BUTTON_TEXT.START} ) : ( - - )} {isResultOpen && result !== undefined && ( - + )} diff --git a/src/components/pages/CategoryPage/RandomRoulette/randomRouletteStateReducer.ts b/src/components/pages/CategoryPage/RandomRoulette/randomRouletteStateReducer.ts index 1bade02a..4bda8c52 100644 --- a/src/components/pages/CategoryPage/RandomRoulette/randomRouletteStateReducer.ts +++ b/src/components/pages/CategoryPage/RandomRoulette/randomRouletteStateReducer.ts @@ -1,4 +1,4 @@ -import { Store } from "types/common"; +import { StoreItemWithHeart } from "types/common"; export const ACTION_TYPES = { SET_BOARD: "SET_BOARD", @@ -11,7 +11,7 @@ type State = { triggerAnimation: boolean; rouletteBoard: string[]; pickedIndex: number | null; - result: Store | undefined; + result: StoreItemWithHeart | undefined; isResultOpen: boolean; }; @@ -22,14 +22,14 @@ type Action = } | { type: typeof ACTION_TYPES.SPIN; - payload: Pick; + payload: Pick; } | { type: typeof ACTION_TYPES.SHOW_RESULT; + payload: Pick; } | { type: typeof ACTION_TYPES.RESET; - payload: Pick; }; export const initialState: State = { @@ -46,21 +46,31 @@ export const randomRouletteStateReducer = (state: State, action: Action) => { return { ...state, rouletteBoard: action.payload.rouletteBoard }; } case ACTION_TYPES.SPIN: { - const { pickedIndex, result, rouletteBoard } = action.payload; + const { pickedIndex, rouletteBoard } = action.payload; return { ...state, - pickedIndex, - result, rouletteBoard, + pickedIndex, triggerAnimation: true, + isResultOpen: false, }; } case ACTION_TYPES.SHOW_RESULT: { - return { ...state, isResultOpen: true }; + const { result, rouletteBoard } = action.payload; + + return { + ...state, + result, + rouletteBoard, + triggerAnimation: false, + isResultOpen: true, + }; } case ACTION_TYPES.RESET: { - return { ...initialState, rouletteBoard: action.payload.rouletteBoard }; + return { + ...initialState, + }; } default: diff --git a/src/components/pages/Login/Login.style.tsx b/src/components/pages/Login/Login.style.tsx index ee436f0d..2d4c4e62 100644 --- a/src/components/pages/Login/Login.style.tsx +++ b/src/components/pages/Login/Login.style.tsx @@ -11,5 +11,5 @@ export const MainContainer = styled.main` align-items: center; gap: 3.125rem; - background-color: ${({ theme }) => theme.white}; + background-color: ${({ theme }) => theme.color.white}; `; diff --git a/src/components/pages/Login/Login.tsx b/src/components/pages/Login/Login.tsx index 2cda360e..8644d05c 100644 --- a/src/components/pages/Login/Login.tsx +++ b/src/components/pages/Login/Login.tsx @@ -10,6 +10,8 @@ import useLogin from "hooks/useLogin"; import sendLoginRequest from "api/login/sendLoginRequest"; +import { useToastContext } from "components/common/Toast/provider/ToastProvider"; + import * as S from "components/pages/Login/Login.style"; function Login() { @@ -20,13 +22,16 @@ function Login() { const code = searchParams.get("code"); const { login } = useLogin(); + const showToast = useToastContext(); const handleLogin = async () => { try { const accessToken = await sendLoginRequest(code as string); login(accessToken); - } catch ({ message }) { - alert(message); + } catch (error: unknown) { + if (error instanceof Error) { + showToast(error.message); + } } finally { navigate(PATHNAME.HOME); } diff --git a/src/components/pages/MyPage/BookmarkListPage/BookmarkListPage.tsx b/src/components/pages/MyPage/BookmarkListPage/BookmarkListPage.tsx index 3b37952a..5dd824ac 100644 --- a/src/components/pages/MyPage/BookmarkListPage/BookmarkListPage.tsx +++ b/src/components/pages/MyPage/BookmarkListPage/BookmarkListPage.tsx @@ -1,12 +1,15 @@ import * as S from "./BookmarkListPage.style"; +import { useEffect } from "react"; import { useQuery } from "react-query"; import { useNavigate } from "react-router-dom"; +import { StoreItemWithoutHeart } from "types/common"; + import { NETWORK } from "constants/api"; +import { MESSAGES } from "constants/messages"; +import { QUERY_KEY } from "constants/queryKey"; import { PATHNAME } from "constants/routes"; -import { LeftIcon } from "asset"; - import fetchBookmarkList from "api/bookmark/fetchBookmarkList"; import Button from "components/common/Button/Button"; @@ -14,26 +17,34 @@ 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 StoreListItemWithoutHeart from "components/common/StoreListItem/\bStoreListItemWithoutHeart"; import Text from "components/common/Text/Text"; function BookmarkListPage() { const navigate = useNavigate(); const { data, isLoading, isFetching, isError, error } = useQuery( - "bookmarkStore", - () => fetchBookmarkList(), + QUERY_KEY.bookmarkStore, + fetchBookmarkList, { - retry: NETWORK.RETRY_COUNT, + retry: NETWORK.NOT_RETRY_COUNT, refetchOnWindowFocus: false, } ); const bookmarkedStoreData = data ?? []; + useEffect(() => { + if (error instanceof Error && error.message === MESSAGES.LOGIN_RETRY) { + alert(error.message); + navigate(PATHNAME.HOME); + } + }, [error]); + return ( - navigate(-1)} /> +
나의 맛집 + + + + ); +} diff --git a/src/components/pages/MyPage/MyPage.tsx b/src/components/pages/MyPage/MyPage.tsx index 907dc961..593d20db 100644 --- a/src/components/pages/MyPage/MyPage.tsx +++ b/src/components/pages/MyPage/MyPage.tsx @@ -1,15 +1,21 @@ import * as S from "./MyPage.style"; import MyReviewItem from "./MyReviewItem/MyReviewItem"; import UserProfile from "./UserProfile/UserProfile"; -import { MdArrowBackIos } from "react-icons/md"; +import { useEffect } from "react"; import { useQuery } from "react-query"; import { useNavigate } from "react-router-dom"; +import { StoreItemWithoutHeart } from "types/common"; + import { NETWORK, SIZE } from "constants/api"; +import { MESSAGES } from "constants/messages"; +import { QUERY_KEY } from "constants/queryKey"; import { PATHNAME } from "constants/routes"; import { RightIcon } from "asset"; +import useLogin from "hooks/useLogin"; + import fetchBookmarkList from "api/bookmark/fetchBookmarkList"; import fetchUserProfile from "api/mypage/fetchUserProfile"; import fetchUserReviewList from "api/mypage/fetchUserReviewList"; @@ -19,34 +25,68 @@ 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 StoreListItemWithoutHeart from "components/common/StoreListItem/\bStoreListItemWithoutHeart"; import Text from "components/common/Text/Text"; function MyPage() { const navigate = useNavigate(); + const { logout } = useLogin(); const { data: profileData, isLoading, isError, - error, - } = useQuery("userProfile", () => fetchUserProfile(), { - retry: NETWORK.RETRY_COUNT, + error: userProfileError, + } = useQuery(QUERY_KEY.userProfile, fetchUserProfile, { refetchOnWindowFocus: false, + retry: NETWORK.NOT_RETRY_COUNT, }); - const { data: bookmarkedStoreData = [] } = useQuery( - "bookmarkedStore", - () => fetchBookmarkList(), + const { data: bookmarkedStoreData = [], error: bookmarkedStoreError } = + useQuery(QUERY_KEY.bookmarkStore, () => fetchBookmarkList(), { + refetchOnWindowFocus: false, + retry: NETWORK.NOT_RETRY_COUNT, + }); + + const { data: myReviewData, error: userReviewError } = useQuery( + "myReview", + fetchUserReviewList, { - retry: NETWORK.RETRY_COUNT, refetchOnWindowFocus: false, + retry: NETWORK.NOT_RETRY_COUNT, } ); - const { data: myReviewData } = useQuery("myReview", fetchUserReviewList, { - retry: NETWORK.RETRY_COUNT, - refetchOnWindowFocus: false, - }); + useEffect(() => { + if ( + userProfileError instanceof Error && + userProfileError.message === MESSAGES.LOGIN_RETRY + ) { + alert(userProfileError.message); + navigate(PATHNAME.HOME); + logout(); + return; + } + + if ( + bookmarkedStoreError instanceof Error && + bookmarkedStoreError.message === MESSAGES.LOGIN_RETRY + ) { + alert(bookmarkedStoreError.message); + logout(); + navigate(PATHNAME.HOME); + return; + } + + if ( + userReviewError instanceof Error && + userReviewError.message === MESSAGES.LOGIN_RETRY + ) { + alert(userReviewError.message); + logout(); + navigate(PATHNAME.HOME); + } + }, [userProfileError, bookmarkedStoreError, userReviewError]); const myReviews = myReviewData?.reviews ?? []; @@ -54,18 +94,11 @@ function MyPage() { return ( - } - onClick={() => { - navigate(-1); - }} - > - 마이페이지 - + 마이페이지
{isLoading && } - {isError && error instanceof Error && ( - + {isError && userProfileError instanceof Error && ( + )}
@@ -79,7 +112,10 @@ function MyPage() { {bookmarkedStoreData.length > 0 ? ( - + + stores={bookmarkedStoreData} + renderListItem={(store) => } + /> ) : ( 저장된 맛집이 없습니다 diff --git a/src/components/pages/MyPage/MyReviewItem/MyReviewItem.tsx b/src/components/pages/MyPage/MyReviewItem/MyReviewItem.tsx index 89ba106b..a348266e 100644 --- a/src/components/pages/MyPage/MyReviewItem/MyReviewItem.tsx +++ b/src/components/pages/MyPage/MyReviewItem/MyReviewItem.tsx @@ -3,20 +3,29 @@ 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 { ReviewInputShape, UserReview } from "types/common"; + +import { MESSAGES } from "constants/messages"; +import { QUERY_KEY } from "constants/queryKey"; import { PATHNAME } from "constants/routes"; +import useLogin from "hooks/useLogin"; + import deleteReviewItem from "api/review/deleteReviewItem"; +import sendReviewItem from "api/review/sendReviewItem"; + +import repeatComponent from "util/repeatComponent"; 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 { useToastContext } from "components/common/Toast/provider/ToastProvider"; -import ReviewUpdateBottomSheet from "components/pages/StoreDetailPage/ReviewUpdateBottomSheet/ReviewUpdateBottomSheet"; +import DeleteReviewModal from "components/pages/MyPage/DeleteReviewModal/DeleteReviewModal"; +import ReviewBottomSheet from "components/pages/StoreDetailPage/ReviewBottomSheet/ReviewBottomSheet"; function MyReviewItem({ id, @@ -28,18 +37,39 @@ function MyReviewItem({ imageUrl, }: UserReview) { const navigate = useNavigate(); + const showToast = useToastContext(); + const queryClient = useQueryClient(); + const { logout } = useLogin(); + + const onSuccess = () => { + queryClient.invalidateQueries(QUERY_KEY.myReview); + queryClient.invalidateQueries( + QUERY_KEY.reviewDetailStore(String(restaurant.id)), + { refetchInactive: true } + ); + }; - const deleteMutation = useMutation(() => - deleteReviewItem({ - restaurantId: String(restaurant.id), - articleId: String(id), - }) + const deleteMutation = useMutation( + () => + deleteReviewItem({ + restaurantId: String(restaurant.id), + articleId: String(id), + }), + { + onSuccess, + onError: (error) => { + if (error.message === MESSAGES.LOGIN_REQUIRED) { + showToast(error.message); + logout(); + navigate(PATHNAME.HOME); + } + }, + } ); const [isDropBoxOpen, setIsDropBoxOpen] = useState(false); const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); - - const queryClient = useQueryClient(); + const [isModalOpen, setModalOpen] = useState(false); const handleMeatballButtonClick = (event: MouseEvent) => { event.stopPropagation(); @@ -56,21 +86,30 @@ function MyReviewItem({ const handleReviewDeleteClick = (event: MouseEvent) => { event.stopPropagation(); - if (window.confirm("정말 삭제하시겠습니까?")) { - deleteMutation.mutate({ - restaurantId: restaurant.id, - id, - }); - } + setModalOpen((prev) => !prev); }; - const handleReviewModalClick = () => { - queryClient.invalidateQueries([ - "reviewDetailStore", - { restaurantId: restaurant.id }, - ]); + const handleSubmitError = (error: AxiosError) => { + if (error.message === MESSAGES.LOGIN_REQUIRED) { + showToast(error.message); + logout(); + navigate(PATHNAME.HOME); + } }; + const mutation = useMutation( + ({ content, menu, rating, imageUrl }) => + sendReviewItem({ + restaurantId: reviewInfo.restaurantId, + articleId: reviewInfo.id, + rating, + menu, + content, + imageUrl: imageUrl ?? "", + }), + { onSuccess, onError: handleSubmitError, retry: 0 } + ); + const reviewInfo = { id: String(id), restaurantId: String(restaurant.id), @@ -84,7 +123,7 @@ function MyReviewItem({ <> @@ -134,6 +173,17 @@ function MyReviewItem({ )} + {isModalOpen && ( + setModalOpen((prev) => !prev)} + onDeleteReview={() => + deleteMutation.mutate({ + restaurantId: restaurant.id, + id, + }) + } + /> + )} {repeatComponent(, rating)} @@ -149,10 +199,10 @@ function MyReviewItem({ {isBottomSheetOpen && ( - setIsBottomSheetOpen(false)} + setIsBottomSheetOpen(false)} + mutate={mutation.mutate} /> )} diff --git a/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.style.ts b/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.style.ts index 6111fbf4..66a220fe 100644 --- a/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.style.ts +++ b/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.style.ts @@ -7,7 +7,7 @@ export const Container = styled.div` export const HeaderWrapper = styled.header` display: flex; - justify-content: space-between; + justify-content: center; background-color: white; `; diff --git a/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.tsx b/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.tsx index af616898..ca536950 100644 --- a/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.tsx +++ b/src/components/pages/MyPage/MyReviewListPage/MyReviewListPage.tsx @@ -1,10 +1,15 @@ import MyReviewItem from "../MyReviewItem/MyReviewItem"; import * as S from "./MyReviewListPage.style"; +import { useEffect } from "react"; import { useInfiniteQuery } from "react-query"; import { useNavigate } from "react-router-dom"; + import { UserReview } from "types/common"; -import { LeftIcon } from "asset"; +import { NETWORK } from "constants/api"; +import { MESSAGES } from "constants/messages"; +import { QUERY_KEY } from "constants/queryKey"; +import { PATHNAME } from "constants/routes"; import getNextPageParam from "api/getNextPageParam"; import fetchUserReviewList from "api/mypage/fetchUserReviewList"; @@ -17,15 +22,16 @@ 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, { + useInfiniteQuery(QUERY_KEY.myReviewList, fetchUserReviewList, { getNextPageParam, + retry: NETWORK.NOT_RETRY_COUNT, }); const loadMoreReviews = () => { - fetchNextPage(); + if (!isError) { + fetchNextPage(); + } }; const reviews = @@ -37,19 +43,25 @@ function MyReviewListPage() { [] ) || []; + const navigate = useNavigate(); + + useEffect(() => { + if (error instanceof Error && error.message === MESSAGES.LOGIN_RETRY) { + alert(error.message); + navigate(PATHNAME.HOME); + } + }, [error]); + return ( - navigate(-1)} /> 나의 리뷰 -
{(isLoading || isFetching) && } - {isError && error instanceof Error && ( + {isError && error instanceof Error ? ( - )} - {reviews.length ? ( + ) : reviews.length > 0 ? ( reviews.map((review) => ( diff --git a/src/components/pages/SearchResultPage/SearchResultPage.tsx b/src/components/pages/SearchResultPage/SearchResultPage.tsx index 1a41ceda..c9224c7e 100644 --- a/src/components/pages/SearchResultPage/SearchResultPage.tsx +++ b/src/components/pages/SearchResultPage/SearchResultPage.tsx @@ -3,10 +3,11 @@ import { useContext } from "react"; import { MdArrowBackIos } from "react-icons/md"; import { useInfiniteQuery } from "react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { Campus, Store } from "types/common"; +import { Campus, StoreItemWithHeart } from "types/common"; import { NETWORK, SIZE } from "constants/api"; import { getCampusId } from "constants/campus"; +import { QUERY_KEY } from "constants/queryKey"; import { PATHNAME } from "constants/routes"; import { campusContext } from "context/CampusContextProvider"; @@ -20,6 +21,7 @@ import InfiniteScroll from "components/common/InfiniteScroll/InfiniteScroll"; import SectionHeader from "components/common/SectionHeader/SectionHeader"; import Spinner from "components/common/Spinner/Spinner"; import StoreList from "components/common/StoreList/StoreList"; +import StoreListItemWithHeart from "components/common/StoreListItem/StoreListItemWithHeart"; import * as S from "components/pages/SearchResultPage/SearchResultPage.style"; @@ -39,7 +41,7 @@ function SearchResultPage() { }; const { data, error, isLoading, isError, fetchNextPage, isFetching } = - useInfiniteQuery(["categoryStore", fetchParams], fetchStoreList, { + useInfiniteQuery(QUERY_KEY.categoryStore(fetchParams), fetchStoreList, { getNextPageParam, retry: NETWORK.RETRY_COUNT, }); @@ -49,28 +51,24 @@ function SearchResultPage() { }; const searchResults = - data?.pages.reduce( + data?.pages.reduce( (stores, page) => [...stores, ...page.restaurants], [] ) || []; return ( - } - onClick={() => { - navigate(-1); - }} - > - {`' ${name} ' 검색결과입니다.`} - + {`' ${name} ' 검색결과입니다.`} {(isLoading || isFetching) && } {isError && error instanceof Error && ( )} {searchResults.length ? ( - + + stores={searchResults} + renderListItem={(store) => } + /> ) : ( 검색 결과가 없습니다. )} diff --git a/src/components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandBottomSheet.tsx b/src/components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandBottomSheet.tsx new file mode 100644 index 00000000..595c7b7a --- /dev/null +++ b/src/components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandBottomSheet.tsx @@ -0,0 +1,90 @@ +import { AxiosError } from "axios"; +import { UseMutateFunction } from "react-query"; + +import { categories } from "constants/categories"; + +import BottomSheet from "components/common/BottomSheet/BottomSheet"; +import Button from "components/common/Button/Button"; +import Input from "components/common/Input/Input"; +import Select from "components/common/Select/Select"; +import { useToastContext } from "components/common/Toast/provider/ToastProvider"; + +import * as S from "components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandBottomSheet.style"; + +const isValidString = (input: unknown): input is string => { + return typeof input === "string" && input.length !== 0; +}; + +interface StoreDemandBottomSheetProps { + initValue?: { categoryId: string; name: string }; + closeSheet: () => void; + mutate: UseMutateFunction< + unknown, + AxiosError, + { + categoryId: string; + name: string; + }, + unknown + >; +} + +function StoreDemandBottomSheet({ + mutate, + initValue, + closeSheet, +}: StoreDemandBottomSheetProps) { + const showToast = useToastContext(); + + const handleSubmit: React.FormEventHandler = (e) => { + e.preventDefault(); + + const formData = new FormData(e.currentTarget); + const name = formData.get("name"); + const categoryId = formData.get("categoryId"); + + if (!isValidString(name) || !isValidString(categoryId)) { + showToast("모든 항목을 작성해주세요!"); + return; + } + + if (name.length > 50) { + showToast("식당 이름의 최대 길이는 50자입니다."); + return; + } + + mutate({ name, categoryId }); + }; + + const categoryOptions = Object.entries(categories).map(([id, name]) => ( + + )); + return ( + + + + + + + + ); +} + +export default StoreDemandBottomSheet; diff --git a/src/components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandCreateBottomSheet.tsx b/src/components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandCreateBottomSheet.tsx deleted file mode 100644 index 55c309b8..00000000 --- a/src/components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandCreateBottomSheet.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { AxiosError } from "axios"; -import { useContext } from "react"; -import { useMutation } from "react-query"; -import { Campus } from "types/common"; - -import { NETWORK } from "constants/api"; -import { getCampusId } from "constants/campus"; -import { categories } from "constants/categories"; -import { MESSAGES } from "constants/messages"; - -import { campusContext } from "context/CampusContextProvider"; - -import useLogin from "hooks/useLogin"; - -import sendStoreDemandPostRequest from "api/store/sendStoreDemandPostRequest"; - -import BottomSheet from "components/common/BottomSheet/BottomSheet"; -import Button from "components/common/Button/Button"; -import Input from "components/common/Input/Input"; -import Select from "components/common/Select/Select"; - -import * as S from "components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandBottomSheet.style"; - -interface StoreDemandCreateBottomSheetProps { - closeSheet: () => void; - refetchList: () => void; -} - -function StoreDemandCreateBottomSheet({ - closeSheet, - refetchList, -}: StoreDemandCreateBottomSheetProps) { - const { logout } = useLogin(); - const campus = useContext(campusContext); - - const categoryOptions = Object.entries(categories).map(([id, name]) => ( - - )); - - const isValidString = (input: unknown): input is string => { - return typeof input === "string" && input.length !== 0; - }; - - const handleSubmit: React.FormEventHandler = (e) => { - e.preventDefault(); - - const formData = new FormData(e.currentTarget); - const name = formData.get("name"); - const categoryId = formData.get("categoryId"); - - if (!isValidString(name) || !isValidString(categoryId)) { - alert("모든 항목을 작성해주세요!"); - return; - } - - if (name.length > 50) { - alert("식당 이름의 최대 길이는 50자입니다."); - return; - } - - mutation.mutate({ name, categoryId }); - }; - - const handleSuccess = () => { - closeSheet(); - refetchList(); - }; - - const handleSubmitError = (error: AxiosError) => { - if (error.code === "401") { - alert(MESSAGES.TOKEN_INVALID); - logout(); - } - }; - - const mutation = useMutation< - unknown, - AxiosError, - { categoryId: string; name: string } - >(sendStoreDemandPostRequest(getCampusId(campus as Campus)), { - onSuccess: handleSuccess, - onError: handleSubmitError, - retry: NETWORK.RETRY_COUNT, - }); - - return ( - - - - - - - - ); -} - -export default StoreDemandCreateBottomSheet; diff --git a/src/components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandEditBottomSheet.tsx b/src/components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandEditBottomSheet.tsx deleted file mode 100644 index e7e9bf00..00000000 --- a/src/components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandEditBottomSheet.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { AxiosError } from "axios"; -import { useContext } from "react"; -import { useMutation } from "react-query"; -import { Campus } from "types/common"; - -import { NETWORK } from "constants/api"; -import { getCampusId } from "constants/campus"; -import { categories } from "constants/categories"; -import { MESSAGES } from "constants/messages"; - -import { campusContext } from "context/CampusContextProvider"; - -import useLogin from "hooks/useLogin"; - -import sendStoreDemandPutRequest from "api/store/sendStoreDemandPutRequest"; - -import BottomSheet from "components/common/BottomSheet/BottomSheet"; -import Button from "components/common/Button/Button"; -import Input from "components/common/Input/Input"; -import Select from "components/common/Select/Select"; - -import * as S from "components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandBottomSheet.style"; - -interface StoreDemandEditBottomSheetProps { - id: string; - initValue: { categoryId: string; name: string }; - closeSheet: () => void; - refetchList: () => void; -} - -function StoreDemandEditBottomSheet({ - id, - initValue, - closeSheet, - refetchList, -}: StoreDemandEditBottomSheetProps) { - const { logout } = useLogin(); - const campus = useContext(campusContext); - - const categoryOptions = Object.entries(categories).map(([id, name]) => ( - - )); - - const isValidString = (input: unknown): input is string => { - return typeof input === "string" && input.length !== 0; - }; - - const handleSubmit: React.FormEventHandler = (e) => { - e.preventDefault(); - - const formData = new FormData(e.currentTarget); - const name = formData.get("name"); - const categoryId = formData.get("categoryId"); - - if (!isValidString(name) || !isValidString(categoryId)) { - alert("모든 항목을 작성해주세요!"); - return; - } - - if (name.length > 50) { - alert("식당 이름의 최대 길이는 50자입니다."); - return; - } - - mutation.mutate({ name, categoryId }); - }; - - const handleSuccess = () => { - closeSheet(); - refetchList(); - }; - - const handleSubmitError = (error: AxiosError) => { - if (error.code === "401") { - alert(MESSAGES.TOKEN_INVALID); - logout(); - } - }; - - const mutation = useMutation< - unknown, - AxiosError, - { categoryId: string; name: string } - >(sendStoreDemandPutRequest(getCampusId(campus as Campus), id), { - onSuccess: handleSuccess, - onError: handleSubmitError, - retry: NETWORK.RETRY_COUNT, - }); - - return ( - - - - - - - - ); -} - -export default StoreDemandEditBottomSheet; diff --git a/src/components/pages/StoreDemandPage/StoreDemandDetailModal/StoreDemandDetailModal.style.tsx b/src/components/pages/StoreDemandPage/StoreDemandDetailModal/StoreDemandDetailModal.style.tsx index ce7599b2..ba50d421 100644 --- a/src/components/pages/StoreDemandPage/StoreDemandDetailModal/StoreDemandDetailModal.style.tsx +++ b/src/components/pages/StoreDemandPage/StoreDemandDetailModal/StoreDemandDetailModal.style.tsx @@ -1,12 +1,6 @@ -import styled from "styled-components"; +import styled, { css } from "styled-components"; -export const ContentContainer = styled.div` - display: flex; - flex-direction: column; -`; - -export const NameContainer = styled.div` - margin-bottom: ${({ theme }) => theme.spacer.spacing4}; +export const TextContainer = styled.div` display: flex; flex-direction: column; @@ -51,7 +45,13 @@ export const RegisteredRow = styled(DetailData)` width: 20%; `; -export const ButtonContainer = styled.div` +export const CloseButtonStyling = css` + position: absolute; + top: ${({ theme }) => theme.spacer.spacing3}; + right: ${({ theme }) => theme.spacer.spacing3}; +`; + +export const ButtonContainerStyling = css` margin-top: ${({ theme }) => theme.spacer.spacing4}; display: flex; gap: ${({ theme }) => theme.spacer.spacing1}; diff --git a/src/components/pages/StoreDemandPage/StoreDemandDetailModal/StoreDemandDetailModal.tsx b/src/components/pages/StoreDemandPage/StoreDemandDetailModal/StoreDemandDetailModal.tsx index b9d57cb1..d2af4baa 100644 --- a/src/components/pages/StoreDemandPage/StoreDemandDetailModal/StoreDemandDetailModal.tsx +++ b/src/components/pages/StoreDemandPage/StoreDemandDetailModal/StoreDemandDetailModal.tsx @@ -1,10 +1,8 @@ -import { AxiosError } from "axios"; import { useContext } from "react"; import { BsCheckCircleFill } from "react-icons/bs"; import { useMutation } from "react-query"; import { Campus, StoreDemand } from "types/common"; -import { NETWORK } from "constants/api"; import { getCampusId } from "constants/campus"; import { categories } from "constants/categories"; import { MESSAGES } from "constants/messages"; @@ -20,6 +18,7 @@ import Button from "components/common/Button/Button"; import Heading from "components/common/Heading/Heading"; import Modal from "components/common/Modal/Modal"; import Text from "components/common/Text/Text"; +import { useToastContext } from "components/common/Toast/provider/ToastProvider"; import * as S from "components/pages/StoreDemandPage/StoreDemandDetailModal/StoreDemandDetailModal.style"; @@ -45,6 +44,7 @@ function StoreDemandDetailModal({ const campus = useContext(campusContext); const isLoggedIn = useContext(LoginContext); const { logout } = useLogin(); + const showToast = useToastContext(); const handleDeleteClick = () => { mutation.mutate(); @@ -55,9 +55,9 @@ function StoreDemandDetailModal({ handleAfterRequest(); }; - const handleSubmitError = (error: AxiosError) => { - if (error.code === "401") { - alert(MESSAGES.TOKEN_INVALID); + const handleSubmitError = (error: Error) => { + if (error.message === MESSAGES.TOKEN_INVALID) { + showToast(MESSAGES.TOKEN_INVALID); logout(); } }; @@ -67,17 +67,23 @@ function StoreDemandDetailModal({ { onSuccess: handleSuccess, onError: handleSubmitError, - retry: NETWORK.RETRY_COUNT, + retry: 0, } ); return ( - - - + + + {campus} {name} - + + + + 신청자 @@ -94,15 +100,17 @@ function StoreDemandDetailModal({ + + {isLoggedIn && isAuthor && !isRegistered && ( - + <> - + )} - + ); } diff --git a/src/components/pages/StoreDemandPage/StoreDemandList/StoreDemandList.tsx b/src/components/pages/StoreDemandPage/StoreDemandList/StoreDemandList.tsx index 10cdce71..3addf901 100644 --- a/src/components/pages/StoreDemandPage/StoreDemandList/StoreDemandList.tsx +++ b/src/components/pages/StoreDemandPage/StoreDemandList/StoreDemandList.tsx @@ -1,14 +1,7 @@ -import { useState } from "react"; -import { BsCheckCircleFill } from "react-icons/bs"; import { StoreDemand } from "types/common"; -import Button from "components/common/Button/Button"; - -import StoreDemandEditBottomSheet from "components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandEditBottomSheet"; -import StoreDemandDetailModal from "components/pages/StoreDemandPage/StoreDemandDetailModal/StoreDemandDetailModal"; import * as S from "components/pages/StoreDemandPage/StoreDemandList/StoreDemandList.style"; - -import { theme } from "style/Theme"; +import StoreDemandListItem from "components/pages/StoreDemandPage/StoreDemandList/StoreDemandListItem/StoreDemandListItem"; interface Props { storeRequests: StoreDemand[]; @@ -16,71 +9,6 @@ interface Props { } function StoreDemandList({ storeRequests, refetchList }: Props) { - const [detailOpenId, setDetailOpenId] = useState(null); - const [editOpenId, setEditOpenId] = useState(null); - - const handleRequestDetailOpen: ( - id: string - ) => React.MouseEventHandler = (id) => () => { - setDetailOpenId(id); - }; - - const handleRequestDetailClose = () => { - setDetailOpenId(null); - }; - - const handleEditOpen = (id: string) => () => { - setEditOpenId(id); - setDetailOpenId(null); - }; - - const handleRequestEditClose = () => { - setEditOpenId(null); - }; - - const sliceStoreName = (name: string) => { - return name.length < 15 ? name : `${name.slice(0, 12)}...`; - }; - - const RequestListItems = storeRequests.map( - ({ id, categoryId, name, author, isRegistered, isAuthor }) => ( - - {sliceStoreName(name)} - - {isRegistered && ( - - )} - - - - - {detailOpenId === id && ( - - )} - {editOpenId === id && ( - - )} - - ) - ); - return ( @@ -88,8 +16,13 @@ function StoreDemandList({ storeRequests, refetchList }: Props) { 등록됨 상세보기 - - {RequestListItems} + {storeRequests.map((storeDemand) => ( + + ))} ); } diff --git a/src/components/pages/StoreDemandPage/StoreDemandList/StoreDemandListItem/StoreDemandListItem.tsx b/src/components/pages/StoreDemandPage/StoreDemandList/StoreDemandListItem/StoreDemandListItem.tsx new file mode 100644 index 00000000..f6ea6a25 --- /dev/null +++ b/src/components/pages/StoreDemandPage/StoreDemandList/StoreDemandListItem/StoreDemandListItem.tsx @@ -0,0 +1,125 @@ +import * as S from "../StoreDemandList.style"; +import { AxiosError } from "axios"; +import { useContext, useState } from "react"; +import { BsCheckCircleFill } from "react-icons/bs"; +import { useMutation } from "react-query"; +import { useNavigate } from "react-router-dom"; +import { Campus, StoreDemand } from "types/common"; + +import { getCampusId } from "constants/campus"; +import { MESSAGES } from "constants/messages"; +import { PATHNAME } from "constants/routes"; + +import { campusContext } from "context/CampusContextProvider"; + +import useLogin from "hooks/useLogin"; + +import sendStoreDemandPutRequest from "api/store/sendStoreDemandPutRequest"; + +import Button from "components/common/Button/Button"; +import { useToastContext } from "components/common/Toast/provider/ToastProvider"; + +import StoreDemandBottomSheet from "components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandBottomSheet"; +import StoreDemandDetailModal from "components/pages/StoreDemandPage/StoreDemandDetailModal/StoreDemandDetailModal"; + +import { theme } from "style/Theme"; + +interface StoreDemandContentListProps { + storeDemand: StoreDemand; + refetchList: () => void; +} + +const sliceStoreName = (name: string) => { + return name.length < 15 ? name : `${name.slice(0, 12)}...`; +}; + +export default function StoreDemandListItem({ + storeDemand: { id, categoryId, name, author, isRegistered, isAuthor }, + refetchList, +}: StoreDemandContentListProps) { + const { logout } = useLogin(); + const showToast = useToastContext(); + const navigate = useNavigate(); + const campus = useContext(campusContext); + + const [detailOpenId, setDetailOpenId] = useState(null); + const [editOpenId, setEditOpenId] = useState(null); + + const handleRequestDetailOpen: ( + id: string + ) => React.MouseEventHandler = (id) => () => { + setDetailOpenId(id); + }; + + const handleRequestDetailClose = () => { + setDetailOpenId(null); + }; + + const handleEditOpen = (id: string) => () => { + setEditOpenId(id); + setDetailOpenId(null); + }; + + const handleRequestEditClose = () => { + setEditOpenId(null); + }; + + const handleSuccess = () => { + handleRequestEditClose(); + refetchList(); + }; + + const handleSubmitError = (error: Error) => { + if (error.message === MESSAGES.TOKEN_INVALID) { + showToast(MESSAGES.TOKEN_INVALID); + logout(); + navigate(PATHNAME.HOME); + } + }; + + const mutation = useMutation< + unknown, + AxiosError, + { categoryId: string; name: string } + >(sendStoreDemandPutRequest(getCampusId(campus as Campus), id), { + onSuccess: handleSuccess, + onError: handleSubmitError, + retry: 0, + }); + + return ( + + {sliceStoreName(name)} + + {isRegistered && ( + + )} + + + + + {detailOpenId === id && ( + + )} + {editOpenId === id && ( + + )} + + ); +} diff --git a/src/components/pages/StoreDemandPage/StoreDemandPage.tsx b/src/components/pages/StoreDemandPage/StoreDemandPage.tsx index f3a4c61c..e25c0e99 100644 --- a/src/components/pages/StoreDemandPage/StoreDemandPage.tsx +++ b/src/components/pages/StoreDemandPage/StoreDemandPage.tsx @@ -1,17 +1,24 @@ +import { AxiosError } from "axios"; import { useState, useContext } from "react"; -import { MdArrowBackIos } from "react-icons/md"; -import { useInfiniteQuery } from "react-query"; +import { useInfiniteQuery, useMutation } from "react-query"; import { useNavigate } from "react-router-dom"; + import { Campus, StoreDemand } from "types/common"; import { NETWORK } from "constants/api"; import { getCampusId } from "constants/campus"; +import { MESSAGES } from "constants/messages"; +import { QUERY_KEY } from "constants/queryKey"; +import { PATHNAME } from "constants/routes"; import { campusContext } from "context/CampusContextProvider"; import { LoginContext } from "context/LoginContextProvider"; +import useLogin from "hooks/useLogin"; + import getNextPageParam from "api/getNextPageParam"; import fetchStoreDemandList from "api/store/fetchStoreDemandList"; +import sendStoreDemandPostRequest from "api/store/sendStoreDemandPostRequest"; import Button from "components/common/Button/Button"; import ErrorImage from "components/common/ErrorImage/ErrorImage"; @@ -19,19 +26,46 @@ import ErrorText from "components/common/ErrorText/ErrorText"; import InfiniteScroll from "components/common/InfiniteScroll/InfiniteScroll"; import SectionHeader from "components/common/SectionHeader/SectionHeader"; import Spinner from "components/common/Spinner/Spinner"; +import { useToastContext } from "components/common/Toast/provider/ToastProvider"; -import StoreDemandCreateBottomSheet from "components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandCreateBottomSheet"; +import StoreDemandBottomSheet from "components/pages/StoreDemandPage/StoreDemandBottomSheet/StoreDemandBottomSheet"; import StoreDemandList from "components/pages/StoreDemandPage/StoreDemandList/StoreDemandList"; import * as S from "components/pages/StoreDemandPage/StoreDemandPage.style"; function StoreDemandPage() { const isLoggedIn = useContext(LoginContext); + const { logout } = useLogin(); + const [isSheetOpen, setSheetOpen] = useState(false); const navigate = useNavigate(); + const showToast = useToastContext(); const campus = useContext(campusContext); const campusId = getCampusId(campus as Campus); + const handleSuccess = () => { + setSheetOpen(false); + refetch(); + }; + + const handleSubmitError = (error: Error) => { + if (error.message === MESSAGES.TOKEN_INVALID) { + showToast(MESSAGES.TOKEN_INVALID); + logout(); + navigate(PATHNAME.HOME); + } + }; + + const mutation = useMutation< + unknown, + AxiosError, + { categoryId: string; name: string } + >(sendStoreDemandPostRequest(getCampusId(campus as Campus)), { + onSuccess: handleSuccess, + onError: handleSubmitError, + retry: 0, + }); + const { data, error, @@ -41,7 +75,7 @@ function StoreDemandPage() { isFetching, refetch, } = useInfiniteQuery( - ["StoreDemand", { campusId: campusId, size: 15 }], + QUERY_KEY.storeDemand({ campusId, size: 15 }), fetchStoreDemandList, { getNextPageParam, @@ -57,7 +91,7 @@ function StoreDemandPage() { const handleRequestSheetOpen = () => { if (!isLoggedIn) { - alert("로그인 후 작성해주세요"); + showToast(MESSAGES.LOGIN_REQUIRED); return; } setSheetOpen(true); @@ -72,14 +106,7 @@ function StoreDemandPage() { > 요청하기 - } - onClick={() => { - navigate(-1); - }} - > - 식당 추가 요청 게시판 - + 식당 추가 요청 게시판 {isError && error instanceof Error && ( )} @@ -95,9 +122,9 @@ function StoreDemandPage() { 가게 정보가 없습니다. )} {isSheetOpen && ( - setSheetOpen(false)} - refetchList={refetch} + mutate={mutation.mutate} /> )}
diff --git a/src/components/pages/StoreDetailPage/ReviewInputBottomSheet/ReviewInputBottomSheet.style.tsx b/src/components/pages/StoreDetailPage/ReviewBottomSheet/ReviewBottomSheet.styled.ts similarity index 82% rename from src/components/pages/StoreDetailPage/ReviewInputBottomSheet/ReviewInputBottomSheet.style.tsx rename to src/components/pages/StoreDetailPage/ReviewBottomSheet/ReviewBottomSheet.styled.ts index 8be590cc..c73eeb9f 100644 --- a/src/components/pages/StoreDetailPage/ReviewInputBottomSheet/ReviewInputBottomSheet.style.tsx +++ b/src/components/pages/StoreDetailPage/ReviewBottomSheet/ReviewBottomSheet.styled.ts @@ -5,7 +5,7 @@ export const Form = styled.form` display: flex; flex-direction: column; - gap: ${({ theme }) => theme.spacer.spacing3}; ; + gap: ${({ theme }) => theme.spacer.spacing3}; `; export const StarRatingWrapper = styled.div` diff --git a/src/components/pages/StoreDetailPage/ReviewInputBottomSheet/ReviewInputBottomSheet.tsx b/src/components/pages/StoreDetailPage/ReviewBottomSheet/ReviewBottomSheet.tsx similarity index 68% rename from src/components/pages/StoreDetailPage/ReviewInputBottomSheet/ReviewInputBottomSheet.tsx rename to src/components/pages/StoreDetailPage/ReviewBottomSheet/ReviewBottomSheet.tsx index 04b9037c..13caa6c1 100644 --- a/src/components/pages/StoreDetailPage/ReviewInputBottomSheet/ReviewInputBottomSheet.tsx +++ b/src/components/pages/StoreDetailPage/ReviewBottomSheet/ReviewBottomSheet.tsx @@ -1,16 +1,13 @@ +import * as S from "./ReviewBottomSheet.styled"; import { AxiosError } from "axios"; import { useState } from "react"; -import { useMutation } from "react-query"; +import { UseMutateFunction } from "react-query"; import { ReviewInputShape } from "types/common"; -import { NETWORK } from "constants/api"; import { MESSAGES } from "constants/messages"; import { INPUT_MAX_LENGTH } from "constants/rules"; import { useImageUpload } from "hooks/useImageUpload"; -import useLogin from "hooks/useLogin"; - -import sendReviewPostRequest from "api/review/sendReviewPostRequest"; import BottomSheet from "components/common/BottomSheet/BottomSheet"; import Button from "components/common/Button/Button"; @@ -19,36 +16,52 @@ import Input from "components/common/Input/Input"; import Label from "components/common/Label/Label"; import StarRating from "components/common/StarRating/StarRating"; import Textarea from "components/common/Textarea/Textarea"; - -import * as S from "components/pages/StoreDetailPage/ReviewInputBottomSheet/ReviewInputBottomSheet.style"; - -interface ReviewInputBottomSheetProps { +import { useToastContext } from "components/common/Toast/provider/ToastProvider"; + +interface ReviewBottomSheetProps { + defaultReviewItem?: { + content: string; + rating: number; + menu: string; + restaurantId: string; + id: string; + imageUrl: string | null; + }; closeSheet: () => void; - restaurantId: string; - onSuccess: () => void; + mutate: UseMutateFunction< + unknown, + AxiosError, + ReviewInputShape, + unknown + >; } const DEFAULT_RATING = 4; -function ReviewInputBottomSheet({ +function ReviewBottomSheet({ + defaultReviewItem, closeSheet, - restaurantId, - onSuccess, -}: ReviewInputBottomSheetProps) { - const [rating, setRating] = useState(DEFAULT_RATING); - const [reviewContent, setReviewContent] = useState(""); - const [menuInput, setMenuInput] = useState(""); - const { uploadedImageUrl, handleImageUpload, handleImageRemoval } = - useImageUpload(); + mutate, +}: ReviewBottomSheetProps) { + const [rating, setRating] = useState( + defaultReviewItem ? defaultReviewItem.rating - 1 : DEFAULT_RATING + ); + const [reviewContent, setReviewContent] = useState( + defaultReviewItem?.content ?? "" + ); + const [menu, setMenu] = useState(defaultReviewItem?.menu ?? ""); - const { logout } = useLogin(); + const showToast = useToastContext(); + + const { uploadedImageUrl, handleImageUpload, handleImageRemoval } = + useImageUpload(showToast, defaultReviewItem?.imageUrl); const handleSubmitRequest: React.FormEventHandler = (e) => { e.preventDefault(); - mutation.mutate({ + mutate({ content: reviewContent, rating: rating + 1, - menu: menuInput, + menu, imageUrl: uploadedImageUrl, }); closeSheet(); @@ -65,11 +78,11 @@ function ReviewInputBottomSheet({ if (value.length > INPUT_MAX_LENGTH.MENU) { e.preventDefault(); - alert(MESSAGES.EXCEED_MENU_MAX_LENGTH); + showToast(MESSAGES.EXCEED_MENU_MAX_LENGTH); return; } - setMenuInput(value); + setMenu(value); }; const handleContentInput: React.ChangeEventHandler = ( @@ -81,25 +94,13 @@ function ReviewInputBottomSheet({ if (value.length > INPUT_MAX_LENGTH.REVIEW_CONTENT) { e.preventDefault(); - alert(MESSAGES.EXCEED_REVIEW_CONTENT_MAX_LENGTH); + showToast(MESSAGES.EXCEED_REVIEW_CONTENT_MAX_LENGTH); return; } setReviewContent(value); }; - const handleSubmitError = (error: AxiosError) => { - if (error.code === "401") { - alert(MESSAGES.TOKEN_INVALID); - logout(); - } - }; - - const mutation = useMutation( - sendReviewPostRequest(restaurantId), - { onSuccess, onError: handleSubmitError, retry: NETWORK.RETRY_COUNT } - ); - return ( @@ -110,7 +111,7 @@ function ReviewInputBottomSheet({ void; - defaultReviewItem: { - content: string; - rating: number; - menu: string; - restaurantId: string; - id: string; - imageUrl: string | null; - }; - onSuccess: () => void; -} - -function ReviewUpdateBottomSheet({ - closeSheet, - defaultReviewItem, - onSuccess, -}: ReviewUpdateBottomSheetProps) { - const [rating, setRating] = useState(defaultReviewItem.rating - 1); - const [reviewContent, setReviewContent] = useState( - defaultReviewItem.content - ); - const [menu, setMenu] = useState(defaultReviewItem.menu); - const { uploadedImageUrl, handleImageUpload, handleImageRemoval } = - useImageUpload(defaultReviewItem.imageUrl); - - const { logout } = useLogin(); - - const handleSubmitRequest: React.FormEventHandler = (e) => { - e.preventDefault(); - mutation.mutate({ - content: reviewContent, - rating: rating + 1, - menu: menu, - imageUrl: uploadedImageUrl, - }); - closeSheet(); - }; - - const handleRatingInput = (input: number) => { - setRating(input); - }; - - const handleMenuInput: React.ChangeEventHandler = (e) => { - const { - target: { value }, - } = e; - - if (value.length > INPUT_MAX_LENGTH.MENU) { - e.preventDefault(); - alert(MESSAGES.EXCEED_MENU_MAX_LENGTH); - return; - } - - setMenu(value); - }; - - const handleContentInput: React.ChangeEventHandler = ( - e - ) => { - const { - target: { value }, - } = e; - - if (value.length > INPUT_MAX_LENGTH.REVIEW_CONTENT) { - e.preventDefault(); - alert(MESSAGES.EXCEED_REVIEW_CONTENT_MAX_LENGTH); - return; - } - - setReviewContent(value); - }; - - const handleSubmitError = (error: AxiosError) => { - if (error.code === "401") { - alert(MESSAGES.TOKEN_EXPIRED); - logout(); - } - }; - - const mutation = useMutation( - () => - sendReviewItem({ - restaurantId: defaultReviewItem.restaurantId, - articleId: defaultReviewItem.id, - rating: rating + 1, - menu, - content: reviewContent, - }), - { onSuccess, onError: handleSubmitError, retry: NETWORK.RETRY_COUNT } - ); - - return ( - - - - - - - -