diff --git a/src/api/detail.ts b/src/api/detail.ts index 811f0e18..a5885e31 100644 --- a/src/api/detail.ts +++ b/src/api/detail.ts @@ -34,6 +34,8 @@ export const getIsWish = async (id: number, setIsWish: React.Dispatch { +export const postWishes = async ({placeId, contentTypeId}: Wishes) => { try { - const response = await axios.post('/api/wishes', {placeId, contentTypeId}); + const response = await axios.post( + '/api/wishes', + {placeId, contentTypeId}, + { + withCredentials: true, + }, + ); console.log('axios 포스트 성공', response); return response.data; } catch (error) { console.error(error); } }; + +// --------------------------- DELETE --------------------------- + +export const deleteWishes = async (id: number) => { + try { + const response = await axios.delete(`/api/wishes/${id}`); + console.log('axios delete success', response); + return response.data; + } catch (error) { + console.error(error); + } +}; diff --git a/src/components/Detail/Contents/Information/BasicInformation/BasicInformation.tsx b/src/components/Detail/Contents/Information/BasicInformation/BasicInformation.tsx index d72cc2dd..b0ec35cf 100644 --- a/src/components/Detail/Contents/Information/BasicInformation/BasicInformation.tsx +++ b/src/components/Detail/Contents/Information/BasicInformation/BasicInformation.tsx @@ -21,10 +21,11 @@ interface BasicInformationProps { openTime: string; title: string; thumbnail: string; + id: number; contentTypeId: number; } -function BasicInformation({location, openTime, title, thumbnail, contentTypeId}: BasicInformationProps) { +function BasicInformation({location, openTime, title, thumbnail, id, contentTypeId}: BasicInformationProps) { return (
기본정보
@@ -33,6 +34,7 @@ function BasicInformation({location, openTime, title, thumbnail, contentTypeId}: lng={location.longitude} title={title} thumbnail={thumbnail} + id={id} contentTypeId={contentTypeId} areaCode={location.areaCode} /> diff --git a/src/components/Detail/Contents/Information/BasicInformation/MapInDetail/MapInDetail.tsx b/src/components/Detail/Contents/Information/BasicInformation/MapInDetail/MapInDetail.tsx index 2dd25b34..696c5eae 100644 --- a/src/components/Detail/Contents/Information/BasicInformation/MapInDetail/MapInDetail.tsx +++ b/src/components/Detail/Contents/Information/BasicInformation/MapInDetail/MapInDetail.tsx @@ -14,12 +14,13 @@ interface MapInDetailProps { lng: number; title: string; thumbnail: string; + id: number; contentTypeId: number; areaCode: number; } // 장소 정보에 따라 마커 다르게 표시 -function MapInDetail({lat, lng, title, thumbnail, contentTypeId, areaCode}: MapInDetailProps) { +function MapInDetail({lat, lng, title, thumbnail, id, contentTypeId, areaCode}: MapInDetailProps) { const {isOpen, onOpen, onClose} = useDisclosure(); // useEffect(() => { @@ -63,6 +64,7 @@ function MapInDetail({lat, lng, title, thumbnail, contentTypeId, areaCode}: MapI thumbnail={thumbnail} contentTypeId={contentTypeId} areaCode={areaCode} + id={id} /> ); diff --git a/src/components/Detail/Contents/Information/BasicInformation/MapModal/MapModal.tsx b/src/components/Detail/Contents/Information/BasicInformation/MapModal/MapModal.tsx index c1092eea..1d9f0b81 100644 --- a/src/components/Detail/Contents/Information/BasicInformation/MapModal/MapModal.tsx +++ b/src/components/Detail/Contents/Information/BasicInformation/MapModal/MapModal.tsx @@ -1,11 +1,11 @@ import {Drawer, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader} from '@chakra-ui/react'; import {AiOutlineLeft} from 'react-icons/ai'; -import {FaRegHeart} from 'react-icons/fa'; import {CustomOverlayMap, Map} from 'react-kakao-maps-sdk'; import styles from './MapModal.module.scss'; import BigHomeMarker from '@/assets/homeIcons/map/house_big.svg?react'; +import WishBtn from '@/components/WishBtn/WishBtn'; interface MapModalProps { isOpen: boolean; @@ -14,11 +14,12 @@ interface MapModalProps { lng: number; title: string; thumbnail: string; + id: number; contentTypeId: number; areaCode: number; } -function MapModal({isOpen, onClose, lat, lng, title, thumbnail, contentTypeId, areaCode}: MapModalProps) { +function MapModal({isOpen, onClose, lat, lng, title, thumbnail, id, contentTypeId, areaCode}: MapModalProps) { return (
- + diff --git a/src/components/Detail/Contents/Information/Information.tsx b/src/components/Detail/Contents/Information/Information.tsx index fd7a6b60..76711263 100644 --- a/src/components/Detail/Contents/Information/Information.tsx +++ b/src/components/Detail/Contents/Information/Information.tsx @@ -29,6 +29,7 @@ function Information({data, onOpen, reviewsRating, reviews}: InformationProps) { openTime={data.openTime} title={data.title} thumbnail={data.thumbnail} + id={data.id} contentTypeId={data.contentTypeId} /> diff --git a/src/components/Detail/Contents/Review/ReviewImageSlider/ReviewImageSlider.tsx b/src/components/Detail/Contents/Review/ReviewImageSlider/ReviewImageSlider.tsx index 638652e9..eea5c7d7 100644 --- a/src/components/Detail/Contents/Review/ReviewImageSlider/ReviewImageSlider.tsx +++ b/src/components/Detail/Contents/Review/ReviewImageSlider/ReviewImageSlider.tsx @@ -1,19 +1,19 @@ -import { useDisclosure } from "@chakra-ui/react"; -import { useState } from "react"; +import {useDisclosure} from '@chakra-ui/react'; +import {useState} from 'react'; -import styles from "./ReviewImageSlider.module.scss"; +import styles from './ReviewImageSlider.module.scss'; -import useComponentSize from "@/hooks/useComponetSize"; +import useComponentSize from '@/hooks/useComponetSize'; -import SlideModal from "@/components/Detail/Main/SlideModal/SlideModal"; -import SlideButton from "@/components/SlideButton/SlideButton"; +import SlideModal from '@/components/Detail/Main/SlideModal/SlideModal'; +import SlideButton from '@/components/SlideButton/SlideButton'; -function ReviewImageSlider({ images }: { images: string[] }) { +function ReviewImageSlider({images}: {images: string[]}) { const [slideLocation, setSlideLocation] = useState(0); const [componentRef, size] = useComponentSize(); const [imageIndex, setImageIndex] = useState(0); - const { isOpen, onOpen, onClose } = useDisclosure(); + const {isOpen, onOpen, onClose} = useDisclosure(); const handleIsOpen = (index: number) => { setImageIndex(index); @@ -36,14 +36,15 @@ function ReviewImageSlider({ images }: { images: string[] }) { itemNumber={images.length} // 목록 전체 넓이 slideSize={size} + buttonSize={24} /> )}
{images.map((data, i) => ( diff --git a/src/components/Detail/Main/ImageSwiper/Swiper.tsx b/src/components/Detail/Main/ImageSwiper/Swiper.tsx index 816ca7f9..fb75c38d 100644 --- a/src/components/Detail/Main/ImageSwiper/Swiper.tsx +++ b/src/components/Detail/Main/ImageSwiper/Swiper.tsx @@ -33,7 +33,7 @@ function ImageSwiper({images}: ImageSwiperProps) { }} > {images.map((data) => ( - + # ))} diff --git a/src/components/Detail/Main/Main.tsx b/src/components/Detail/Main/Main.tsx index 120f742b..c5c78e1f 100644 --- a/src/components/Detail/Main/Main.tsx +++ b/src/components/Detail/Main/Main.tsx @@ -8,23 +8,15 @@ interface MainProps { contentTypeId: number; images: string[]; title: string; - category: string; rating: number; reviewsCount: number; } -function Main({id, contentTypeId, images, title, category, rating, reviewsCount}: MainProps) { +function Main({id, contentTypeId, images, title, rating, reviewsCount}: MainProps) { return (
- + <Title id={id} contentTypeId={contentTypeId} title={title} rating={rating} reviewsCount={reviewsCount} /> </div> ); } diff --git a/src/components/Detail/Main/Title/Title.tsx b/src/components/Detail/Main/Title/Title.tsx index ab791230..6878c368 100644 --- a/src/components/Detail/Main/Title/Title.tsx +++ b/src/components/Detail/Main/Title/Title.tsx @@ -1,79 +1,37 @@ -import {FaRegHeart} from 'react-icons/fa'; -import {FaHeart} from 'react-icons/fa'; import {GoStarFill} from 'react-icons/go'; import {IoShareSocialOutline} from 'react-icons/io5'; -import {useRecoilState, useRecoilValue, useSetRecoilState} from 'recoil'; import styles from './Title.module.scss'; import CustomToast from '@/components/CustomToast/CustomToast'; -import {IsHeartValued, IsLoginState} from '@/recoil/detail/detail'; -import {isModalOpenState, modalContentState} from '@/recoil/vote/alertModal'; -import {useGetIsWish, usePostWishes} from '@/hooks/Detail/useWish'; +import WishBtn from '@/components/WishBtn/WishBtn'; +import {translateCategoryToStr} from '@/hooks/Search/useSearch'; interface TitleProps { id: number; contentTypeId: number; title: string; - category: string; rating: number; reviewsCount: number; } -function Title({id, contentTypeId, title, category, rating, reviewsCount}: TitleProps) { - const [isWish, setIsWish] = useRecoilState(IsHeartValued); - const setIsModalOpen = useSetRecoilState(isModalOpenState); - const setModalContent = useSetRecoilState(modalContentState); - - useGetIsWish(id, setIsWish); - const postWishes = usePostWishes(); - - const isLogin = useRecoilValue(IsLoginState); - - const notLoginContent = { - title: '로그인이 필요한 기능입니다.', - subText: '로그인하고 모든 서비스를 이용해 보세요! ', - cancelText: '닫기', - actionButton: '로그인하기', - isSmallSize: true, - }; - - const showNotLoginModal = () => { - setIsModalOpen(true); - setModalContent({...notLoginContent}); - }; - +function Title({id, contentTypeId, title, rating, reviewsCount}: TitleProps) { const showToast = CustomToast(); - const handleHeartClick = () => { - if (isLogin) { - if (!isWish) { - showToast('찜 목록에 저장되었습니다.'); - postWishes.mutate({placeId: id, contentTypeId: contentTypeId}); - } - setIsWish(!isWish); - } else { - showNotLoginModal(); - } - }; + const categoryStr = translateCategoryToStr(contentTypeId); return ( <div className={styles.container}> <h2 className={styles.container__header}>{title}</h2> - <p className={styles.container__category}>{category}</p> + <p className={styles.container__category}>{categoryStr}</p> <div className={styles.container__alignCenter}> <GoStarFill className={styles.container__alignCenter__star} /> <span className={styles.container__alignCenter__point}>{rating}</span> <span className={styles.container__alignCenter__reviewsCount}>{`(${reviewsCount})`}</span> </div> <div className={styles.container__positionAbsoluteIcons}> - {isWish ? ( - <FaHeart fontSize='2.4rem' cursor='pointer' color='#E23774' onClick={handleHeartClick} /> - ) : ( - <FaRegHeart fontSize='2.4rem' cursor='pointer' onClick={handleHeartClick} /> - )} - + <WishBtn placeId={id} contentTypeId={contentTypeId} size={'2.4rem'} /> <IoShareSocialOutline fontSize='2.4rem' cursor='pointer' diff --git a/src/components/Detail/Navigation/MeatballBottomSlide/MeatballBottomSlide.tsx b/src/components/Detail/Navigation/MeatballBottomSlide/MeatballBottomSlide.tsx index fdd8d3f6..760a88ac 100644 --- a/src/components/Detail/Navigation/MeatballBottomSlide/MeatballBottomSlide.tsx +++ b/src/components/Detail/Navigation/MeatballBottomSlide/MeatballBottomSlide.tsx @@ -1,51 +1,64 @@ -import { BiTask } from "react-icons/bi"; -import { CiEdit } from "react-icons/ci"; -import { FaRegHeart } from "react-icons/fa"; -import { IoShareSocialOutline } from "react-icons/io5"; -import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; +import {BiTask} from 'react-icons/bi'; +import {CiEdit} from 'react-icons/ci'; +import {FaRegHeart} from 'react-icons/fa'; +import {IoShareSocialOutline} from 'react-icons/io5'; +import {useRecoilState, useSetRecoilState} from 'recoil'; -import styles from "./MeatballBottomSlide.module.scss"; +import styles from './MeatballBottomSlide.module.scss'; -import CustomToast from "@/components/CustomToast/CustomToast"; +import CustomToast from '@/components/CustomToast/CustomToast'; -import CloseIcon from "@/assets/close.svg?react"; -import { IsHeartValued, IsLoginState } from "@/recoil/detail/detail"; -import { isModalOpenState, modalContentState } from "@/recoil/vote/alertModal"; +import CloseIcon from '@/assets/close.svg?react'; +import {IsHeartValued} from '@/recoil/detail/detail'; +import {isModalOpenState, modalContentState} from '@/recoil/vote/alertModal'; -import RegistrationSlide from "../../BottomFixedBtn/RegistrationSlide/RegistrationSlide"; -import ReviewBottomSlide from "../../Contents/ReviewBottomSlide/ReviewBottomSlide"; +import RegistrationSlide from '../../BottomFixedBtn/RegistrationSlide/RegistrationSlide'; +import ReviewBottomSlide from '../../Contents/ReviewBottomSlide/ReviewBottomSlide'; -import { NavigationMeatballProps } from "@/types/detail"; +import {NavigationMeatballProps} from '@/types/detail'; +import {useDeleteWishes, usePostWishes} from '@/hooks/Detail/useWish'; -const MeatballBottomSlide = ({ - onBottomSlideOpen, - onClose, -}: NavigationMeatballProps) => { - const [isHeart, setIsHeart] = useRecoilState(IsHeartValued); +const MeatballBottomSlide = ({onBottomSlideOpen, onClose, id, contentTypeId}: NavigationMeatballProps) => { + const [isWish, setIsWish] = useRecoilState(IsHeartValued); const setIsModalOpen = useSetRecoilState(isModalOpenState); const setModalContent = useSetRecoilState(modalContentState); - const isLogin = useRecoilValue(IsLoginState); + + // isLogin 구현해야 함 + const isLogin = true; const notLoginContent = { - title: "로그인이 필요한 기능입니다.", - subText: "로그인하고 모든 서비스를 이용해 보세요! ", - cancelText: "닫기", - actionButton: "로그인하기", + title: '로그인이 필요한 기능입니다.', + subText: '로그인하고 모든 서비스를 이용해 보세요! ', + cancelText: '닫기', + actionButton: '로그인하기', isSmallSize: true, }; const showNotLoginModal = () => { setIsModalOpen(true); - setModalContent({ ...notLoginContent }); + setModalContent({...notLoginContent}); }; const showToast = CustomToast(); + const postWishes = usePostWishes(); + const deleteWishes = useDeleteWishes(); + const handleHeartClick = () => { - if (!isHeart) { - showToast("찜 목록에 저장되었습니다."); - } + if (isLogin) { + if (!isWish) { + postWishes.mutate({placeId: id, contentTypeId: contentTypeId}); - setIsHeart(!isHeart); + setIsWish(true); + showToast('찜 목록에 저장되었습니다.'); + } else { + deleteWishes.mutate(id); + + showToast('찜 목록에서 제거되었습니다.'); + setIsWish(false); + } + } else { + showNotLoginModal(); + } }; return ( @@ -53,11 +66,11 @@ const MeatballBottomSlide = ({ <button onClick={() => { onClose(); - document.body.style.removeProperty("overflow"); + document.body.style.removeProperty('overflow'); }} className={styles.container__top} > - <CloseIcon width="2rem" height="2rem" /> + <CloseIcon width='2rem' height='2rem' /> </button> <button onClick={() => { @@ -65,14 +78,14 @@ const MeatballBottomSlide = ({ if (isLogin) { handleHeartClick(); onClose(); - document.body.style.removeProperty("overflow"); + document.body.style.removeProperty('overflow'); } else { showNotLoginModal(); } }} > <div className={styles.container__iconWrapper}> - <FaRegHeart fontSize="1.6rem" /> + <FaRegHeart fontSize='1.6rem' /> </div> <p>찜하기</p> </button> @@ -80,15 +93,12 @@ const MeatballBottomSlide = ({ onClick={() => { onClose(); setTimeout(() => { - onBottomSlideOpen( - <RegistrationSlide slideOnClose={onClose} />, - false, - ); + onBottomSlideOpen(<RegistrationSlide slideOnClose={onClose} />, false); }, 300); }} > <div className={styles.container__iconWrapper}> - <BiTask fontSize="1.6rem" /> + <BiTask fontSize='1.6rem' /> </div> <p>후보에 추가</p> </button> @@ -96,27 +106,24 @@ const MeatballBottomSlide = ({ onClick={() => { onClose(); setTimeout(() => { - onBottomSlideOpen( - <ReviewBottomSlide slideOnClose={onClose} />, - true, - ); + onBottomSlideOpen(<ReviewBottomSlide slideOnClose={onClose} />, true); }, 300); }} > <div className={styles.container__iconWrapper}> - <CiEdit fontSize="1.6rem" /> + <CiEdit fontSize='1.6rem' /> </div> <p>리뷰 쓰기</p> </button> <button onClick={() => { - showToast("링크가 복사되었습니다."); + showToast('링크가 복사되었습니다.'); onClose(); - document.body.style.removeProperty("overflow"); + document.body.style.removeProperty('overflow'); }} > <div className={styles.container__iconWrapper}> - <IoShareSocialOutline fontSize="1.6rem" /> + <IoShareSocialOutline fontSize='1.6rem' /> </div> <p>공유하기</p> </button> diff --git a/src/components/WishBtn/WishBtn.tsx b/src/components/WishBtn/WishBtn.tsx new file mode 100644 index 00000000..37839599 --- /dev/null +++ b/src/components/WishBtn/WishBtn.tsx @@ -0,0 +1,72 @@ +import {FaHeart, FaRegHeart} from 'react-icons/fa'; +import {useRecoilState, useSetRecoilState} from 'recoil'; +import {IsHeartValued} from '@/recoil/detail/detail'; +import {isModalOpenState, modalContentState} from '@/recoil/vote/alertModal'; +import CustomToast from '../CustomToast/CustomToast'; +import {useDeleteWishes, useGetIsWish, usePostWishes} from '@/hooks/Detail/useWish'; + +interface WishBtnProps { + placeId: number; + contentTypeId: number; + size: string; + className?: string; +} + +const notLoginContent = { + title: '로그인이 필요한 기능입니다.', + subText: '로그인하고 모든 서비스를 이용해 보세요! ', + cancelText: '닫기', + actionButton: '로그인하기', + isSmallSize: true, +}; + +function WishBtn({placeId, contentTypeId, size = '2.4rem', className = ''}: WishBtnProps) { + const [isWish, setIsWish] = useRecoilState(IsHeartValued); + const setIsModalOpen = useSetRecoilState(isModalOpenState); + const setModalContent = useSetRecoilState(modalContentState); + + // isLogin 구현해야 함 + const isLogin = true; + + const showNotLoginModal = () => { + setIsModalOpen(true); + setModalContent({...notLoginContent}); + }; + + const showToast = CustomToast(); + + useGetIsWish(placeId, setIsWish); + const postWishes = usePostWishes(); + const deleteWishes = useDeleteWishes(); + + // postWishes error 리턴 시 로그인 모달 띄우기 + const handleWishClick = () => { + if (isLogin) { + if (!isWish) { + postWishes.mutate({placeId: placeId, contentTypeId: contentTypeId}); + + setIsWish(true); + showToast('찜 목록에 저장되었습니다.'); + } else { + deleteWishes.mutate(placeId); + + showToast('찜 목록에서 제거되었습니다.'); + setIsWish(false); + } + } else { + showNotLoginModal(); + } + }; + + return ( + <> + {isWish ? ( + <FaHeart fontSize={size} cursor='pointer' color='#E23774' onClick={handleWishClick} className={className} /> + ) : ( + <FaRegHeart fontSize={size} cursor='pointer' onClick={handleWishClick} className={className} /> + )} + </> + ); +} + +export default WishBtn; diff --git a/src/hooks/Detail/useWish.ts b/src/hooks/Detail/useWish.ts index f0854f56..b8f17fb0 100644 --- a/src/hooks/Detail/useWish.ts +++ b/src/hooks/Detail/useWish.ts @@ -1,4 +1,4 @@ -import {PostWishes, getIsWish} from '@/api/detail'; +import {deleteWishes, getIsWish, postWishes} from '@/api/detail'; import {useSuspenseQuery} from '@tanstack/react-query'; import {useCustomMutation} from '../Votes/vote'; @@ -10,5 +10,9 @@ export const useGetIsWish = (id: number, setIsWish: React.Dispatch<React.SetStat }; export const usePostWishes = () => { - return useCustomMutation(PostWishes, ['isWish']); + return useCustomMutation(postWishes, ['isWish']); +}; + +export const useDeleteWishes = () => { + return useCustomMutation(deleteWishes, ['isWish']); }; diff --git a/src/pages/Detail/Detail.tsx b/src/pages/Detail/Detail.tsx index e111e55e..92b989c4 100644 --- a/src/pages/Detail/Detail.tsx +++ b/src/pages/Detail/Detail.tsx @@ -59,7 +59,12 @@ function Detail() { <Navigation onOpen={() => onBottomSlideOpen( - <MeatballBottomSlide onBottomSlideOpen={onBottomSlideOpen} onClose={handleSlideOnClose} />, + <MeatballBottomSlide + onBottomSlideOpen={onBottomSlideOpen} + onClose={handleSlideOnClose} + id={placeInfo.id} + contentTypeId={placeInfo.contentTypeId} + />, false, ) } @@ -69,7 +74,6 @@ function Detail() { contentTypeId={placeInfo.contentTypeId} images={placeInfo.gallery} title={placeInfo.title} - category={placeInfo.category} rating={reviewsRating.rating} reviewsCount={reviewsRating.userRatingCount} /> diff --git a/src/types/detail.ts b/src/types/detail.ts index a9c81e91..d4815970 100644 --- a/src/types/detail.ts +++ b/src/types/detail.ts @@ -37,6 +37,8 @@ export interface NavigationProps { export interface NavigationMeatballProps { onBottomSlideOpen: (content: ReactNode, isReview: boolean) => void; onClose: () => void; + id: number; + contentTypeId: number; } // Main