diff --git a/src/assets/icons/BackwordIcon.tsx b/src/assets/icons/BackwordIcon.tsx new file mode 100644 index 0000000..6ed5a5c --- /dev/null +++ b/src/assets/icons/BackwordIcon.tsx @@ -0,0 +1,21 @@ +const BackwordIcon = () => { + return ( + + + + ); +}; + +export default BackwordIcon; diff --git a/src/assets/icons/ListIcon.tsx b/src/assets/icons/ListIcon.tsx new file mode 100644 index 0000000..c47dd01 --- /dev/null +++ b/src/assets/icons/ListIcon.tsx @@ -0,0 +1,21 @@ +const ListIcon = () => { + return ( + + + + ); +}; + +export default ListIcon; diff --git a/src/assets/icons/RemoveListIcon.tsx b/src/assets/icons/RemoveListIcon.tsx new file mode 100644 index 0000000..928723f --- /dev/null +++ b/src/assets/icons/RemoveListIcon.tsx @@ -0,0 +1,21 @@ +const RemoveListIcon = () => { + return ( + + + + ); +}; + +export default RemoveListIcon; diff --git a/src/assets/icons/WriteIcon.tsx b/src/assets/icons/WriteIcon.tsx new file mode 100644 index 0000000..e087a71 --- /dev/null +++ b/src/assets/icons/WriteIcon.tsx @@ -0,0 +1,18 @@ +const WriteIcon = () => { + return ( + + + + ); +}; + +export default WriteIcon; diff --git a/src/components/bottomSheet/BottomSheet.tsx b/src/components/bottomSheet/BottomSheet.tsx index 6b5c80b..aff2f07 100644 --- a/src/components/bottomSheet/BottomSheet.tsx +++ b/src/components/bottomSheet/BottomSheet.tsx @@ -1,8 +1,10 @@ import { useAtom } from 'jotai'; import styled from 'styled-components'; +import RestDetailView from '~/components/bottomSheet/reaturantDetail/RestDetailView'; import RestaurantSummary from '~/components/bottomSheet/restaurantSummary/RestaurantSummary'; +import useGetDetailRestaurants from '~/hooks/api/restaurants/useGetDetailRestaurants'; import useDraggable from '~/hooks/useDraggable'; -import { restaurantAtom } from '~/store/restaurants'; +import { restaurantAtom, selectedRestaurantIdAtom } from '~/store/restaurants'; type BottomSheetProps = { onClose: () => void; @@ -43,16 +45,38 @@ const BottomSheetContent = styled.div` const BottomSheet: React.FC = ({ onClose }) => { const { translateY, handleMouseDown } = useDraggable(onClose); const [restaurants] = useAtom(restaurantAtom); - + const [selectedId, setSelectedId] = useAtom(selectedRestaurantIdAtom); + const { + data: restaurantDetail, + isLoading, + isError, + } = useGetDetailRestaurants(selectedId || 0); + const moreButtonClick = (id: number) => { + setSelectedId(id); + }; return ( -
    - {restaurants?.map((restaurant) => ( - - ))} -
+ {selectedId ? ( + <> + {isLoading &&

Loading...

} + {isError &&

Error fetching data

} + {restaurantDetail && ( + + )} + + ) : ( +
    + {restaurants?.map((restaurant) => ( + moreButtonClick(restaurant.id)} + /> + ))} +
+ )}
); diff --git a/src/components/bottomSheet/reaturantDetail/BackButton.tsx b/src/components/bottomSheet/reaturantDetail/BackButton.tsx new file mode 100644 index 0000000..3bb46db --- /dev/null +++ b/src/components/bottomSheet/reaturantDetail/BackButton.tsx @@ -0,0 +1,49 @@ +import { useSetAtom } from 'jotai'; +import styled from 'styled-components'; +import BackwordIcon from '~/assets/icons/BackwordIcon'; +import { selectedRestaurantIdAtom } from '~/store/restaurants'; + +const BackButton = () => { + const setSelectedId = useSetAtom(selectedRestaurantIdAtom); + const clickHandler = () => { + setSelectedId(null); + }; + + return ( + + ); +}; + +export default BackButton; + +const Button = styled.button` + position: absolute; + top: -30px; + width: 24px; + height: 24px; + margin-bottom: 8px; + background-color: ${({ theme }) => theme.colors.white}; + border: none; + font-size: 10px; + font-weight: ${({ theme }) => theme.fontWeights.Bold}; + text-align: center; + border-radius: 4px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; + cursor: pointer; + & svg > path { + stroke: ${({ theme }) => theme.colors.black}; + } + + &:hover { + background-color: ${({ theme }) => theme.colors.whitegray}; + & svg > path { + stroke: ${({ theme }) => theme.colors.gray}; + } + } +`; diff --git a/src/components/bottomSheet/reaturantDetail/ListButton.tsx b/src/components/bottomSheet/reaturantDetail/ListButton.tsx new file mode 100644 index 0000000..cfb9b91 --- /dev/null +++ b/src/components/bottomSheet/reaturantDetail/ListButton.tsx @@ -0,0 +1,116 @@ +import { useSetAtom } from 'jotai'; +import { useState } from 'react'; +import { styled } from 'styled-components'; +import ListIcon from '~/assets/icons/ListIcon'; +import RemoveListIcon from '~/assets/icons/RemoveListIcon'; +import XIcon from '~/assets/icons/XIcon'; +import useDelRestaurant from '~/hooks/api/restaurants/useDelRestaurant'; +import usePostRestaurant from '~/hooks/api/restaurants/usePostRestarurant'; +import { selectedRestaurantIdAtom } from '~/store/restaurants'; +interface ListButtonProps { + restaurantId: number; + isAdd: boolean; +} + +const ListButton: React.FC = ({ restaurantId, isAdd }) => { + const [isVisible, setIsVisible] = useState(true); + const setSelectedId = useSetAtom(selectedRestaurantIdAtom); + const { refetch: postRest } = usePostRestaurant(restaurantId); + const { refetch: delRest } = useDelRestaurant(restaurantId); + + const addList = (e: React.MouseEvent) => { + e.preventDefault(); + postRest(); + setSelectedId(null); + }; + + const delList = (e: React.MouseEvent) => { + e.preventDefault(); + delRest(); + setSelectedId(null); + }; + + return ( + + {isAdd ? ( + <> + + + 맛집 리스트에 추가! + + + ) : ( + <> + + + 맛집 리스트에서 제거 + + + )} + setIsVisible(false)} $isAdd={isAdd}> + + + + ); +}; + +export default ListButton; + +const ButtonWrapper = styled.div<{ $isVisible: boolean; $isAdd: boolean }>` + display: ${({ $isVisible }) => ($isVisible ? 'flex' : 'none')}; + justify-content: space-between; + align-items: center; + margin-top: 16px; + background-color: ${({ $isAdd, theme }) => + $isAdd ? theme.colors.orange : theme.colors.whitegray}; + border-radius: 8px; +`; + +const AddButton = styled.button` + color: ${({ theme }) => theme.colors.white}; + flex: 1; + height: 60px; + padding: 8px 16px; + border: none; + background: none; + cursor: pointer; + + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + + font-size: 16px; + font-weight: ${({ theme }) => theme.fontWeights.Bold}; +`; + +const RemoveButton = styled.button` + color: ${({ theme }) => theme.colors.orange}; + flex: 1; + height: 60px; + padding: 8px 16px; + font-size: 16px; + border: none; + background: none; + cursor: pointer; + + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + + font-size: 16px; + font-weight: ${({ theme }) => theme.fontWeights.Bold}; +`; + +const CloseButton = styled.button<{ $isAdd: boolean }>` + border: none; + background: none; + cursor: pointer; + padding: 20px; + + & svg > path { + stroke: ${({ $isAdd, theme }) => + $isAdd ? theme.colors.white : theme.colors.orange}; + } +`; diff --git a/src/components/bottomSheet/reaturantDetail/PlatformRate.tsx b/src/components/bottomSheet/reaturantDetail/PlatformRate.tsx new file mode 100644 index 0000000..44eba6a --- /dev/null +++ b/src/components/bottomSheet/reaturantDetail/PlatformRate.tsx @@ -0,0 +1,67 @@ +import { styled } from 'styled-components'; + +interface PlatformRateProps { + platform: string; + rating: number | string | null; +} + +export const PlatformRate: React.FC = ({ + platform, + rating, +}) => { + if (typeof rating === 'string') { + rating = parseFloat(rating); + } + const score = rating ? rating : 0; + return ( + + {platform} + + {score.toFixed(1)} + + + + + ); +}; + +const Wrapper = styled.div` + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 8px; +`; + +const PlatformName = styled.div` + font-size: 14px; + font-weight: ${({ theme }) => theme.fontWeights.Regular}; + width: 40px; + text-align: center; + flex-shrink: 0; +`; + +const ColDevider = styled.div` + width: 1px; + height: 12px; + background-color: ${({ theme }) => theme.colors.whitegray}; +`; + +const ScoreText = styled.div` + font-size: 14px; + font-weight: ${({ theme }) => theme.fontWeights.Regular}; + flex-shrink: 0; +`; + +const BackgroundBar = styled.div` + display: flex; + width: 100%; + height: 3px; + background-color: ${({ theme }) => theme.colors.whitegray}; + border-radius: 3px; +`; + +const FilledBar = styled.div<{ $filledPercentage: number }>` + width: ${({ $filledPercentage }) => $filledPercentage}%; + background-color: ${({ theme }) => theme.colors.orange}; + border-radius: 3px; +`; diff --git a/src/components/bottomSheet/reaturantDetail/RatingBox.tsx b/src/components/bottomSheet/reaturantDetail/RatingBox.tsx new file mode 100644 index 0000000..88e6664 --- /dev/null +++ b/src/components/bottomSheet/reaturantDetail/RatingBox.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { styled } from 'styled-components'; +import { PlatformRate } from '~/components/bottomSheet/reaturantDetail/PlatformRate'; + +interface RatingBoxProps { + rating_average: string; + rating_google: string; + rating_kakao: string; + rating_naver: string; +} + +const RatingBox: React.FC = ({ + rating_average, + rating_google, + rating_kakao, + rating_naver, +}) => { + return ( + <> + 평점 + +
+ {Number(rating_average).toFixed(1)} + +
+ + + + + +
+ + ); +}; + +export default RatingBox; + +const Title = styled.h4` + display: inline-block; + margin-top: 24px; + font-size: 16px; + font-weight: ${({ theme }) => theme.fontWeights.Bold}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +`; + +const RateWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-top: 8px; +`; + +const RateNumber = styled.div` + font-size: 40px; + font-weight: ${({ theme }) => theme.fontWeights.ExtraBold}; +`; + +const Rate = () => ( + + + + + + + + + + + + +); + +const PlatformRateWrapper = styled.div` + flex: 1; +`; diff --git a/src/components/bottomSheet/reaturantDetail/RestDetailView.tsx b/src/components/bottomSheet/reaturantDetail/RestDetailView.tsx new file mode 100644 index 0000000..26b3870 --- /dev/null +++ b/src/components/bottomSheet/reaturantDetail/RestDetailView.tsx @@ -0,0 +1,85 @@ +import { styled } from 'styled-components'; +import BackButton from '~/components/bottomSheet/reaturantDetail/BackButton'; +import ListButton from '~/components/bottomSheet/reaturantDetail/ListButton'; +import RatingBox from '~/components/bottomSheet/reaturantDetail/RatingBox'; +import Reviews from '~/components/bottomSheet/reaturantDetail/Reviews'; +import RestaurantImgBox from '~/components/bottomSheet/restaurantSummary/RestaurantImgBox'; +import StarRating from '~/components/bottomSheet/restaurantSummary/StarRating'; +import { RestaurantDetail } from '~/types/restaurants'; + +interface RestaurantDetailProps { + restaurantDetail: RestaurantDetail; +} + +const RestDetailView: React.FC = ({ + restaurantDetail, +}) => { + return ( +
+ + + + + + {restaurantDetail.name} + {restaurantDetail.food_type} + + + + + {/* TODO: isAdd에 대한 처리가 필요합니다. */} + + + + +
+ ); +}; + +export default RestDetailView; + +const SummaryWrapper = styled.div` + position: relative; + display: flex; + justify-content: flex-start; + align-items: center; + gap: 8px; +`; + +const SummaryInfo = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 1; + max-width: calc(100% - 16px - 16px - 50px - 68px); +`; + +const Title = styled.h3` + display: inline-block; + font-size: 16px; + font-weight: ${({ theme }) => theme.fontWeights.Regular}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +`; + +const Category = styled.p` + display: inline-block; + margin-top: 6px; + font-size: 14px; + font-weight: ${({ theme }) => theme.fontWeights.Light}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +`; + +const Divider = styled.div` + margin: 14px; + height: 1px; + background-color: ${({ theme }) => theme.colors.whitegray}; +`; diff --git a/src/components/bottomSheet/reaturantDetail/ReviewContent.tsx b/src/components/bottomSheet/reaturantDetail/ReviewContent.tsx new file mode 100644 index 0000000..25def4b --- /dev/null +++ b/src/components/bottomSheet/reaturantDetail/ReviewContent.tsx @@ -0,0 +1,141 @@ +import { styled } from 'styled-components'; +import usePostRecommend from '~/hooks/api/restaurants/usePostRecommend'; +import { Review } from '~/types/restaurants'; + +interface ReviewContentProps { + review: Review; + restaurentId: number; +} + +const formatDate = (date: string) => { + const dateObj = new Date(date); + const year = dateObj.getFullYear(); + const month = String(dateObj.getMonth() + 1).padStart(2, '0'); + const day = String(dateObj.getDate()).padStart(2, '0'); + return `${year}.${month}.${day}`; +}; + +const countFormat = (count: number) => { + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}k`; + } + return count; +}; + +const ReviewContent: React.FC = ({ + review, + restaurentId, +}) => { + const mutation = usePostRecommend(restaurentId, review.id); + + const recommendClick = () => { + mutation.mutate( + { evaluation: 1 }, + { + onSuccess: () => { + alert('좋아요를 반영했습니다.'); + }, + onError: (error) => { + console.error('좋아요 반영에 실패했습니다.', error); + }, + }, + ); + }; + + const decommendClick = () => { + mutation.mutate( + { evaluation: 0 }, + { + onSuccess: () => { + alert('싫어요를 반영했습니다.'); + }, + onError: (error) => { + console.error('싫어요 반영에 실패했습니다.', error); + }, + }, + ); + }; + + return ( + +
+ {review.content} + + {review.user_name} / {formatDate(review.date)} + + {/* 답글보기 기능은 아직 다른 기능을 구현하는데 집중하고 있어서 구현하지 않았습니다. */} + {/* 답글 보기 ({review.replies_count}) */} +
+ + +
GOOD
+
{countFormat(review.recommend_count)}
+
+ +
BAD
+
{countFormat(review.decommend_count)}
+
+
+
+ ); +}; + +export default ReviewContent; + +const ReviewBox = styled.div` + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 8px; + padding: 16px; + border-radius: 4px; + background-color: ${({ theme }) => theme.colors.whitegray}; +`; + +const ContentText = styled.div` + color: ${({ theme }) => theme.colors.gray}; + font-size: 14px; + font-weight: ${({ theme }) => theme.fontWeights.Bold}; +`; + +const SubText = styled.div` + color: ${({ theme }) => theme.colors.gray}; + font-size: 12px; + font-weight: ${({ theme }) => theme.fontWeights.Light}; +`; + +// const LookReply = styled.button` +// display: inline-block; +// margin-top: 8px; +// font-size: 12px; +// font-weight: ${({ theme }) => theme.fontWeights.Light}; +// color: ${({ theme }) => theme.colors.gray}; +// background-color: transparent; +// border: none; +// cursor: pointer; +// `; + +const RecommendBox = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + margin: 4px 0; +`; + +const Recommend = styled.button` + display: flex; + justify-content: space-between; + gap: 20px; + color: ${({ theme }) => theme.colors.orange}; + border: none; + cursor: pointer; +`; + +const Decommend = styled.button` + display: flex; + justify-content: space-between; + gap: 20px; + color: ${({ theme }) => theme.colors.gray}; + border: none; + cursor: pointer; +`; diff --git a/src/components/bottomSheet/reaturantDetail/ReviewWrite.tsx b/src/components/bottomSheet/reaturantDetail/ReviewWrite.tsx new file mode 100644 index 0000000..51c6ec1 --- /dev/null +++ b/src/components/bottomSheet/reaturantDetail/ReviewWrite.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState } from 'react'; +import { styled } from 'styled-components'; +import WriteIcon from '~/assets/icons/WriteIcon'; +import usePostReview from '~/hooks/api/restaurants/usePostReview'; + +interface ReviewWriteProps { + restaurentId: number; +} + +const ReviewWrite: React.FC = ({ restaurentId }) => { + const [isOpen, setIsOpen] = useState(false); + const [content, setContent] = useState(''); + const containerRef = useRef(null); + const inputRef = useRef(null); + const { refetch } = usePostReview(restaurentId, { content }); + + const toggleOpen = () => { + setIsOpen(!isOpen); + if (!isOpen) { + setTimeout(() => { + inputRef.current?.focus(); + }, 300); + } + }; + + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const handleSubmit = () => { + if (!content) { + return; + } + refetch(); + setContent(''); + setIsOpen(false); + }; + + return ( + + {isOpen ? ( + + setContent(e.target.value)} + placeholder="한줄평을 작성해주세요." + onClick={(e) => e.stopPropagation()} // input 클릭 시 이벤트 전파 막기 + /> + + + SUBMIT + + + ) : ( + + + WRITE + + )} + + ); +}; + +export default ReviewWrite; + +const WriteButton = styled.div<{ $isOpen: boolean }>` + z-index: 200; + position: fixed; + bottom: 20px; + right: 20px; + width: ${({ $isOpen }) => ($isOpen ? '90%' : '60px')}; + height: ${({ $isOpen }) => ($isOpen ? '80px' : '60px')}; + border-radius: 6px; + background-color: ${({ theme }) => theme.colors.white}; + + color: ${({ $isOpen, theme }) => + $isOpen ? theme.colors.white : theme.colors.orange}; + font-size: ${({ $isOpen }) => ($isOpen ? '16px' : '18px')}; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease-in-out; + overflow: hidden; + box-shadow: 0 5px 8px rgba(0, 0, 0, 0.1); +`; + +const InputContainer = styled.form<{ $isOpen: boolean }>` + display: ${({ $isOpen }) => ($isOpen ? 'flex' : 'none')}; + align-items: center; + gap: 8px; + padding: 10px; + width: 100%; + height: 100%; + box-sizing: border-box; +`; + +const TextInput = styled.input` + width: 100%; + padding: 10px; + font-size: 14px; + border: none; + border-radius: 8px; + outline: none; +`; + +const SubmitButton = styled.button` + width: 60px; + height: 60px; + padding: 4px; + flex-shrink: 0; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + background-color: ${({ theme }) => theme.colors.orange}; + color: ${({ theme }) => theme.colors.white}; + svg > path { + fill: ${({ theme }) => theme.colors.white}; + } + font-size: 10px; + border: none; + border-radius: 4px; + cursor: pointer; + box-shadow: 0 5px 8px rgba(0, 0, 0, 0.1); +`; + +const WriteStart = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + + svg > path { + fill: ${({ theme }) => theme.colors.orange}; + } + + font-size: 10px; + font-weight: ${({ theme }) => theme.fontWeights.Bold}; +`; diff --git a/src/components/bottomSheet/reaturantDetail/Reviews.tsx b/src/components/bottomSheet/reaturantDetail/Reviews.tsx new file mode 100644 index 0000000..a00ed48 --- /dev/null +++ b/src/components/bottomSheet/reaturantDetail/Reviews.tsx @@ -0,0 +1,36 @@ +import { styled } from 'styled-components'; +import ReviewContent from '~/components/bottomSheet/reaturantDetail/ReviewContent'; +import ReviewWrite from '~/components/bottomSheet/reaturantDetail/ReviewWrite'; +import { Review } from '~/types/restaurants'; + +interface ReviewsProps { + reviews: Review[]; + restaurentId: number; +} + +const Reviews: React.FC = ({ reviews, restaurentId }) => { + return ( + <> + 한줄평 + {reviews && + reviews.map((review) => ( + + ))} + + + ); +}; + +export default Reviews; + +const Title = styled.h4` + display: inline-block; + margin-top: 24px; + font-size: 16px; + font-weight: ${({ theme }) => theme.fontWeights.Bold}; + max-width: 100%; +`; diff --git a/src/components/bottomSheet/restaurantSummary/MoreButton.tsx b/src/components/bottomSheet/restaurantSummary/MoreButton.tsx index 3c15dd7..4cbbd29 100644 --- a/src/components/bottomSheet/restaurantSummary/MoreButton.tsx +++ b/src/components/bottomSheet/restaurantSummary/MoreButton.tsx @@ -1,6 +1,10 @@ import { styled } from 'styled-components'; import MoreIcon from '~/assets/icons/MoreIcon'; +interface MoreButtonProps { + moreButtonClick: () => void; +} + const Button = styled.button` flex-shrink: 0; width: 50px; @@ -31,10 +35,9 @@ const Button = styled.button` } `; -const MoreButton = () => { +const MoreButton: React.FC = ({ moreButtonClick }) => { const handleClick = () => { - // TODO: 더보기 버튼 클릭 시 상세 정보 표시 - console.log('MoreButton'); + moreButtonClick(); }; return ( diff --git a/src/components/bottomSheet/restaurantSummary/RestaurantImgBox.tsx b/src/components/bottomSheet/restaurantSummary/RestaurantImgBox.tsx index 90ec454..de04e8d 100644 --- a/src/components/bottomSheet/restaurantSummary/RestaurantImgBox.tsx +++ b/src/components/bottomSheet/restaurantSummary/RestaurantImgBox.tsx @@ -1,18 +1,17 @@ -// 가로 세로 크기는 68px * 68px 입니다. - import { styled } from 'styled-components'; -const ImgWrapper = styled.div` - width: 68px; - height: 68px; -`; +interface ImgProps { + imgUrl: string; +} -const RestaurantImgBox = () => { - return ( - - restaurantImg - - ); +const RestaurantImgBox: React.FC = ({ imgUrl }) => { + return restaurantImg; }; export default RestaurantImgBox; + +const Img = styled.img` + width: 68px; + height: 68px; + object-fit: cover; +`; diff --git a/src/components/bottomSheet/restaurantSummary/RestaurantSummary.tsx b/src/components/bottomSheet/restaurantSummary/RestaurantSummary.tsx index 24c440f..2c397fc 100644 --- a/src/components/bottomSheet/restaurantSummary/RestaurantSummary.tsx +++ b/src/components/bottomSheet/restaurantSummary/RestaurantSummary.tsx @@ -5,7 +5,8 @@ import StarRating from '~/components/bottomSheet/restaurantSummary/StarRating'; import { Restaurant } from '~/types/restaurants'; interface RestaurantSummaryProps { - restaurant: Restaurant; // Restaurant 타입 사용 + restaurant: Restaurant; + moreButtonClick: () => void; } const SummaryWrapper = styled.div` @@ -46,17 +47,17 @@ const Category = styled.p` const RestaurantSummary: React.FC = ({ restaurant, + moreButtonClick, }) => { return ( - + {restaurant.name} - {/* TODO: address가 아니라 카테고리로 변경 */} - {restaurant.address} + {restaurant.food_type} - + ); }; diff --git a/src/components/bottomSheet/restaurantSummary/StarRating.tsx b/src/components/bottomSheet/restaurantSummary/StarRating.tsx index 88e105e..0e1a55b 100644 --- a/src/components/bottomSheet/restaurantSummary/StarRating.tsx +++ b/src/components/bottomSheet/restaurantSummary/StarRating.tsx @@ -4,7 +4,7 @@ import HalfStar from '~/assets/ratingStar/HalfStar'; import VoidStar from '~/assets/ratingStar/VoidStar'; type StarRatingProps = { - rating: number | null; // 5.0 만점 기준의 평점 + rating: string; // 5.0 만점 기준의 평점 }; const StarWrapper = styled.div` @@ -13,12 +13,11 @@ const StarWrapper = styled.div` `; const StarRating: React.FC = ({ rating }) => { - if (rating === null) { - rating = 0; - } + const ratingNumber = + !isNaN(Number(rating)) && Number(rating) >= 0 ? Number(rating) : 0; - const fullStars = Math.floor(rating); // 정수 부분의 개수 (FullStar 개수) - const halfStar = rating % 1 >= 0.5; // 0.5점 이상일 경우 HalfStar 사용 + const fullStars = Math.floor(ratingNumber); // 정수 부분의 개수 (FullStar 개수) + const halfStar = ratingNumber % 1 >= 0.5; // 0.5점 이상일 경우 HalfStar 사용 const voidStars = 5 - fullStars - (halfStar ? 1 : 0); // 나머지 VoidStar 개수 return ( diff --git a/src/components/map/UserLocation.tsx b/src/components/map/UserLocation.tsx index 64c99fb..2a05e9c 100644 --- a/src/components/map/UserLocation.tsx +++ b/src/components/map/UserLocation.tsx @@ -20,7 +20,9 @@ const UserLocation: React.FC = ({ navermaps }) => { {error ? ( ) : ( <> diff --git a/src/components/navBar/NavBar.tsx b/src/components/navBar/NavBar.tsx index 7a2399c..1c4fa50 100644 --- a/src/components/navBar/NavBar.tsx +++ b/src/components/navBar/NavBar.tsx @@ -1,13 +1,13 @@ import styled from 'styled-components'; import FriendsIcon from '~/assets/icons/FriendsIcon'; import HomeIcon from '~/assets/icons/HomeIcon'; -import MyListIcon from '~/assets/icons/MyListIcon'; +// import MyListIcon from '~/assets/icons/MyListIcon'; import ProfileIcon from '~/components/navBar/ProfileIcon'; import NavItemWrapper from '~/components/navBar/NavItemWrapper'; import { useLocation } from 'react-router-dom'; interface NavBarProps { - handleSearchVisible: () => void; + handleSearchVisible?: () => void; } const Nav = styled.nav` @@ -34,7 +34,7 @@ const NavList = styled.ul` `; const navItems = [ - { path: '/myList', label: 'My List', Icon: MyListIcon }, + // { path: '/myList', label: 'My List', Icon: MyListIcon }, { path: '/friends', label: 'Friends', Icon: FriendsIcon }, { path: '/profile', label: 'Profile', Icon: ProfileIcon }, ]; diff --git a/src/components/search/SearchResultLine.tsx b/src/components/search/SearchResultLine.tsx index 76fc835..40807b4 100644 --- a/src/components/search/SearchResultLine.tsx +++ b/src/components/search/SearchResultLine.tsx @@ -1,6 +1,8 @@ +import { useSetAtom } from 'jotai'; import { styled } from 'styled-components'; import { Divider } from '~/components/search/Divider'; import SmallStarRating from '~/components/search/SmallStarRating'; +import { selectedRestaurantIdAtom } from '~/store/restaurants'; import { Restaurant } from '~/types/restaurants'; const Title = styled.h3` @@ -38,6 +40,7 @@ const LineWrapper = styled.div` justify-content: space-between; align-items: flex-start; gap: 8px; + cursor: pointer; `; const Info = styled.div` @@ -50,9 +53,14 @@ const Info = styled.div` `; const SearchResultLine: React.FC = (restaurant) => { + const setSelectedId = useSetAtom(selectedRestaurantIdAtom); + + const searchClick = () => { + setSelectedId(restaurant.id); + }; return ( <> - + {restaurant.name}
{restaurant.address}
diff --git a/src/components/search/SmallStarRating.tsx b/src/components/search/SmallStarRating.tsx index 9cf7480..f176ebb 100644 --- a/src/components/search/SmallStarRating.tsx +++ b/src/components/search/SmallStarRating.tsx @@ -2,7 +2,7 @@ import { styled } from 'styled-components'; import SmallStar from '~/assets/icons/SmallStar'; type SmallRatingProps = { - rating: number | null; + rating: string | null; }; const RatingWrapper = styled.div` @@ -16,11 +16,11 @@ const RatingText = styled.div` `; const SmallStarRating: React.FC = ({ rating }) => { - rating = rating === null ? 0 : rating; + const ratingNumber = Number(rating); return ( - {rating.toFixed(1)} / 5.0 + {ratingNumber.toFixed(1)} / 5.0 ); }; diff --git a/src/hooks/api/restaurants/useDelRestaurant.ts b/src/hooks/api/restaurants/useDelRestaurant.ts new file mode 100644 index 0000000..1923ca7 --- /dev/null +++ b/src/hooks/api/restaurants/useDelRestaurant.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { del } from '~/libs/api'; + +export const delRestaurantQueryKey = (restaurantId: number) => [ + 'restaurants', + restaurantId, +]; + +const useDelRestaurant = (restaurantId: number) => { + return useQuery({ + queryKey: delRestaurantQueryKey(restaurantId), + queryFn: () => del(`/restaurants/${restaurantId}/`), + enabled: false, + }); +}; + +export default useDelRestaurant; diff --git a/src/hooks/api/restaurants/useGetDetailRestaurants.ts b/src/hooks/api/restaurants/useGetDetailRestaurants.ts new file mode 100644 index 0000000..9b4da0e --- /dev/null +++ b/src/hooks/api/restaurants/useGetDetailRestaurants.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { get } from '~/libs/api'; +import { RestaurantDetail } from '~/types/restaurants'; + +const useGetDetailRestaurants = (id: number) => { + return useQuery({ + queryKey: ['restaurants', id], + queryFn: () => get(`/restaurants/${id}/detail`), + enabled: !!id, + }); +}; + +export default useGetDetailRestaurants; diff --git a/src/hooks/api/restaurants/usePostRecommend.ts b/src/hooks/api/restaurants/usePostRecommend.ts new file mode 100644 index 0000000..c5d8d3d --- /dev/null +++ b/src/hooks/api/restaurants/usePostRecommend.ts @@ -0,0 +1,31 @@ +import { useMutation } from '@tanstack/react-query'; +import { AxiosRequestConfig } from 'axios'; +import { post } from '~/libs/api'; + +type Request = { + evaluation: number; +}; + +export const postRecommendQueryKey = (reviewId: number) => [ + 'recommend', + reviewId, +]; + +const usePostRecommend = (restaurantId: number, reviewId: number) => { + const mutation = useMutation({ + mutationFn: (request: Request) => { + const config: AxiosRequestConfig = { + data: request, + }; + + return post( + `/restaurants/${restaurantId}/reviews/${reviewId}/`, + config, + ); + }, + }); + + return mutation; +}; + +export default usePostRecommend; diff --git a/src/hooks/api/restaurants/usePostRestarurant.ts b/src/hooks/api/restaurants/usePostRestarurant.ts new file mode 100644 index 0000000..8b8e035 --- /dev/null +++ b/src/hooks/api/restaurants/usePostRestarurant.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { post } from '~/libs/api'; + +export const postRestaurantQueryKey = (restaurantId: number) => [ + 'restaurants', + restaurantId, +]; + +const usePostRestaurant = (restaurantId: number) => { + return useQuery({ + queryKey: postRestaurantQueryKey(restaurantId), + queryFn: () => post(`/restaurants/${restaurantId}/`), + enabled: false, + }); +}; + +export default usePostRestaurant; diff --git a/src/hooks/api/restaurants/usePostReview.ts b/src/hooks/api/restaurants/usePostReview.ts new file mode 100644 index 0000000..39655ff --- /dev/null +++ b/src/hooks/api/restaurants/usePostReview.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { post } from '~/libs/api'; +import { Review } from '~/types/restaurants'; + +type Request = { + content: string; +}; + +export const postReviewQueryKey = (request: Request) => ['review', request]; + +const usePostReview = (restaurantId: number, request: Request) => { + return useQuery({ + queryKey: postReviewQueryKey(request), + queryFn: () => + post(`/restaurants/${restaurantId}/reviews/`, request), + enabled: false, + }); +}; + +export default usePostReview; diff --git a/src/libs/api.ts b/src/libs/api.ts index b6fe9ac..ef6ab38 100644 --- a/src/libs/api.ts +++ b/src/libs/api.ts @@ -1,16 +1,29 @@ -import axios, { AxiosError, type AxiosResponse } from 'axios'; +import axios, { + AxiosError, + InternalAxiosRequestConfig, + type AxiosResponse, +} from 'axios'; const baseURL = import.meta.env.VITE_SERVER_URL; -const token = localStorage.getItem('access_token'); const instance = axios.create({ baseURL, timeout: 15000, - headers: { - Authorization: `Bearer ${token}`, - }, withCredentials: true, }); +instance.interceptors.request.use( + (config) => { + const token = localStorage.getItem('access_token'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + }, +); + const interceptorResponseFulfilled = (res: AxiosResponse) => { if (200 <= res.status && res.status < 300) { return res.data; @@ -19,7 +32,36 @@ const interceptorResponseFulfilled = (res: AxiosResponse) => { return Promise.reject(res.data); }; -const interceptorResponseRejected = (error: AxiosError) => { +const interceptorResponseRejected = async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { + _retry?: boolean; + }; + + if ( + originalRequest && + error.response?.status === 401 && + !originalRequest._retry + ) { + originalRequest._retry = true; + try { + const refreshToken = localStorage.getItem('refresh_token'); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + const response = await axios.post(`${baseURL}refresh/`, { + refresh: refreshToken, + }); + const newAccessToken = response.data.access; + localStorage.setItem('access_token', newAccessToken); + + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + return instance(originalRequest); + } catch (refreshError) { + return Promise.reject(refreshError); + } + } + return Promise.reject(error); }; diff --git a/src/libs/axios.tsx b/src/libs/axios.tsx index 04964e4..29f52f6 100644 --- a/src/libs/axios.tsx +++ b/src/libs/axios.tsx @@ -1,13 +1,73 @@ -import axios from 'axios'; +import axios, { + AxiosError, + AxiosResponse, + InternalAxiosRequestConfig, +} from 'axios'; -const token = localStorage.getItem('access_token'); +const baseURL = import.meta.env.VITE_SERVER_URL; const instance = axios.create({ - baseURL: 'https://43.203.225.31.nip.io', - headers: { - Authorization: `Bearer ${token}`, - }, + baseURL, withCredentials: true, }); +instance.interceptors.request.use( + (config) => { + const token = localStorage.getItem('access_token'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + }, +); + +const interceptorResponseFulfilled = (res: AxiosResponse) => { + if (200 <= res.status && res.status < 300) { + return res.data; + } + + return Promise.reject(res.data); +}; + +const interceptorResponseRejected = async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { + _retry?: boolean; + }; + + if ( + originalRequest && + error.response?.status === 401 && + !originalRequest._retry + ) { + originalRequest._retry = true; + try { + const refreshToken = localStorage.getItem('refresh_token'); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + const response = await axios.post(`${baseURL}refresh/`, { + refresh: refreshToken, + }); + const newAccessToken = response.data.access; + localStorage.setItem('access_token', newAccessToken); + + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + return instance(originalRequest); + } catch (refreshError) { + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); +}; + +instance.interceptors.response.use( + interceptorResponseFulfilled, + interceptorResponseRejected, +); + export default instance; diff --git a/src/pages/FriendPage.tsx b/src/pages/FriendPage.tsx index c2e22e4..236c609 100644 --- a/src/pages/FriendPage.tsx +++ b/src/pages/FriendPage.tsx @@ -159,11 +159,7 @@ const FriendPage = () => { ))} - + ); }; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 4746bdd..7cefd17 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -6,14 +6,15 @@ import { useLocation } from 'react-router-dom'; import NavBar from '~/components/navBar/NavBar'; import BottomSheet from '~/components/bottomSheet/BottomSheet'; import useGetRestaurants from '~/hooks/api/useGetRestaurants'; -import { useSetAtom } from 'jotai'; -import { restaurantAtom } from '~/store/restaurants'; +import { useAtom, useSetAtom } from 'jotai'; +import { restaurantAtom, selectedRestaurantIdAtom } from '~/store/restaurants'; import Head from '~/components/common/Head'; const HomePage = () => { const location = useLocation(); const setRestaurants = useSetAtom(restaurantAtom); - const { data } = useGetRestaurants(); + const [selectedId] = useAtom(selectedRestaurantIdAtom); + const { data, refetch } = useGetRestaurants(); const [isBottomSheetVisible, setIsBottomSheetVisible] = useState(false); const [isSearchVisible, setSearchVisible] = useState( @@ -21,9 +22,17 @@ const HomePage = () => { ); useEffect(() => { - if (data) { - setRestaurants(data); + if (selectedId !== null) { + setIsBottomSheetVisible(true); + setSearchVisible(false); + window.history.pushState(null, '', '/'); } + console.log('selectedId', selectedId); + refetch(); + }, [selectedId, refetch]); + + useEffect(() => { + setRestaurants(data || []); }, [data, setRestaurants]); const handleMapClick = () => { diff --git a/src/store/restaurants.ts b/src/store/restaurants.ts index 0d1513e..f949b35 100644 --- a/src/store/restaurants.ts +++ b/src/store/restaurants.ts @@ -6,3 +6,5 @@ export const restaurantAtom = atom([]); // 검색 결과를 저장하는 atom export const searchRestaurantAtom = atom([]); + +export const selectedRestaurantIdAtom = atom(null); diff --git a/src/types/restaurants.d.ts b/src/types/restaurants.d.ts index d809a13..dad1f63 100644 --- a/src/types/restaurants.d.ts +++ b/src/types/restaurants.d.ts @@ -6,14 +6,30 @@ export interface GeoLocation { } export interface Restaurant extends GeoLocation { + address: string; + food_type: string | null; id: number; + image_url: string; + name: string; - food_type: string | null; - rating_average: number | null; - rating_naver: number | null; - rating_kakao: number | null; - rating_google: number | null; - address: string; + rating_average: string; + rating_naver: string; + rating_kakao: string; + rating_google: string; } export type Restaurants = Reaurant[]; + +export interface Review { + content: string; + date: string; + decommend_count: number; + id: number; + recommend_count: number; + replies_count: number; + user_name: string; +} + +export interface RestaurantDetail extends Restaurant { + reviews: Review[]; +} diff --git a/src/utils/README.md b/src/utils/README.md deleted file mode 100644 index 57cbc02..0000000 --- a/src/utils/README.md +++ /dev/null @@ -1 +0,0 @@ -자주 사용하는 유틸리티 함수 저장 \ No newline at end of file