diff --git a/src/api/trips.ts b/src/api/trips.ts index 3d2d2635..be019a7f 100644 --- a/src/api/trips.ts +++ b/src/api/trips.ts @@ -47,7 +47,13 @@ export const getTripsLike = async ( // 우리의 관심 목록 등록 export const postTripsLike = async (tripId: number, tourItemIds: number[]) => { - const res = await client.post(`trips/${tripId}/tripLikedTours`, tourItemIds); + const requestBody = { + tourItemIds: tourItemIds, + }; + const res = await authClient.post( + `trips/${tripId}/tripLikedTours`, + requestBody, + ); return res; }; diff --git a/src/components/common/button/ListSelectBtn.tsx b/src/components/common/button/ListSelectBtn.tsx new file mode 100644 index 00000000..4328e6d2 --- /dev/null +++ b/src/components/common/button/ListSelectBtn.tsx @@ -0,0 +1,57 @@ +import { useState, ReactNode } from 'react'; +import { CheckIcon } from '../icons/Icons'; + +interface ListSelectBtnProps { + children: ReactNode; + onClick?: () => void; +} + +export const ListSelectBtn = ({ children, onClick }: ListSelectBtnProps) => { + const [isActive, setIsActive] = useState(false); + + const handleClick = () => { + setIsActive(!isActive); + if (onClick) { + onClick(); + } + }; + + return ( + + ); +}; + +interface ListCheckBtnProps { + onClick?: () => void; +} + +export const ListCheckBtn = ({ onClick }: ListCheckBtnProps) => { + const [isActive, setIsActive] = useState(false); + + const handleClick = () => { + setIsActive(!isActive); + if (onClick) { + onClick(); + } + }; + + return ( +
+ +
+ ); +}; diff --git a/src/pages/plan/OurLikedList.tsx b/src/pages/plan/OurLikedList.tsx new file mode 100644 index 00000000..bb192296 --- /dev/null +++ b/src/pages/plan/OurLikedList.tsx @@ -0,0 +1,100 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import ToursCategoryItem from '@components/Tours/ToursCategoryItem'; +import { useEffect, useState } from 'react'; +import { Spinner } from '@components/common/spinner/Spinner'; +import { getMemberTours } from '@api/member'; + +export const OurLikedList = () => { + const categories = ['전체', '숙소', '식당', '관광지']; + + // const [selectedContentTypeId, setSelectedContentTypeId] = useState< + // null | number + // >(null); + + const [selectedCategory, setSelectedCategory] = useState('전체'); + useEffect(() => { + console.log(selectedCategory); + }, [selectedCategory]); + + const handleSelectCategory = (category: string) => { + setSelectedCategory(category); + }; + + // useEffect(() => { + // console.log('searchWord: ' + searchWord); + // }, [searchWord]); + // console.log(); + + const { + // fetchNextPage, hasNextPage, + data, + isLoading, + isError, + } = useInfiniteQuery({ + queryKey: ['wishList'], + queryFn: ({ pageParam = 0 }) => getMemberTours(pageParam, 10), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + if ( + lastPage && + lastPage.data && + lastPage.data && + lastPage.data.pageable + ) { + const currentPage = lastPage.data.pageable.pageNumber; + const totalPages = lastPage.data.totalPages; + + if (currentPage < totalPages - 1) { + return currentPage + 1; + } + } + return undefined; + }, + }); + + // const handleCategoryClick = (contentTypeId: number | null) => { + // setSelectedContentTypeId(contentTypeId); + // }; + + if (isLoading) { + return ; + } + if (isError) { + console.log('error fetching search result '); + } + + // console.log(data?.pages[0].data.content); + const searchResults = data?.pages.flatMap((page) => page.data.content) || []; + console.log('searchResults', searchResults); + const noResults = searchResults && searchResults.length === 0; + + return ( + <> +
우리의 관심 목록
+
+ {categories.map((category) => ( + + ))} +
+ {noResults ? ( +
+ 나의 관심목록이 없습니다. +
+ ) : ( +
+ // + )} + + ); +}; diff --git a/src/pages/plan/addPlace/AddtoListBtn.tsx b/src/pages/plan/addPlace/AddtoListBtn.tsx new file mode 100644 index 00000000..9e54f742 --- /dev/null +++ b/src/pages/plan/addPlace/AddtoListBtn.tsx @@ -0,0 +1,36 @@ +import { useRecoilValue } from 'recoil'; +import { selectedItemsState } from '@recoil/listItem'; +import { ButtonPrimary } from '@components/common/button/Button'; +import { postTripsLike } from '@api/trips'; +// import { useNavigate } from 'react-router-dom'; + +const AddToListButton = () => { + const selectedTourItemIds = useRecoilValue(selectedItemsState); + // const navigate = useNavigate(); + + const getTripIdFromUrl = () => { + const pathSegments = window.location.pathname.split('/'); + const tripIdIndex = + pathSegments.findIndex((segment) => segment === 'trip') + 1; + return pathSegments[tripIdIndex] + ? parseInt(pathSegments[tripIdIndex], 10) + : null; + }; + + const handleAddClick = async () => { + const tripId = getTripIdFromUrl(); + if (tripId) { + try { + const response = await postTripsLike(tripId, selectedTourItemIds); + console.log('API response:', response); + // navigate(`/trip/${tripId}`); + } catch (error) { + console.error('API error:', error); + } + } + }; + + return 추가하기; +}; + +export default AddToListButton; diff --git a/src/pages/plan/addPlace/MyLiked.tsx b/src/pages/plan/addPlace/MyLiked.tsx new file mode 100644 index 00000000..5e65a8d3 --- /dev/null +++ b/src/pages/plan/addPlace/MyLiked.tsx @@ -0,0 +1,73 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { Spinner } from '@components/common/spinner/Spinner'; +import { getMemberTours } from '@api/member'; +import { MyLikedList } from './MyLikedList'; +import WishCategory from '@components/Wish/WishCategory'; +import AddToListButton from './AddtoListBtn'; + +export const MyLiked = () => { + const [selectedContentTypeId, setSelectedContentTypeId] = useState< + null | number + >(null); + + const handleCategoryClick = (contentTypeId: number | null) => { + setSelectedContentTypeId(contentTypeId); + }; + + const { fetchNextPage, hasNextPage, data, isLoading, isError } = + useInfiniteQuery({ + queryKey: ['wishList'], + queryFn: ({ pageParam = 0 }) => getMemberTours(pageParam, 10), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + if ( + lastPage && + lastPage.data && + lastPage.data && + lastPage.data.pageable + ) { + const currentPage = lastPage.data.pageable.pageNumber; + const totalPages = lastPage.data.totalPages; + + if (currentPage < totalPages - 1) { + return currentPage + 1; + } + } + return undefined; + }, + }); + + if (isLoading) { + return ; + } + if (isError) { + console.log('error fetching search result '); + } + + const searchResults = data?.pages.flatMap((page) => page.data.content) || []; + const noResults = searchResults && searchResults.length === 0; + + return ( + <> +
나의 관심 목록
+ + {noResults ? ( +
+ 나의 관심목록이 없습니다. +
+ ) : ( + + )} +
+ +
+ + ); +}; diff --git a/src/pages/plan/addPlace/MyLikedList.tsx b/src/pages/plan/addPlace/MyLikedList.tsx new file mode 100644 index 00000000..295328c6 --- /dev/null +++ b/src/pages/plan/addPlace/MyLikedList.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import InfiniteScroll from 'react-infinite-scroller'; +import { v4 as uuidv4 } from 'uuid'; +import { MyLikedListItem } from './MyLikedListItem'; +import { TourType } from '@/@types/tours.types'; +import { Spinner } from '@components/common/spinner/Spinner'; + +interface WishListProps { + toursData: { pages: Array<{ data: { content: TourType[] } }> }; + fetchNextPage: () => void; + hasNextPage: boolean; + isLoading: boolean; + selectedContentTypeId: number | null; +} + +export const MyLikedList: React.FC = ({ + toursData, + fetchNextPage, + hasNextPage, + isLoading, + selectedContentTypeId, +}) => { + if (!toursData || toursData.pages.length === 0) { + return
데이터를 불러오는 중 오류가 발생했습니다.
; + } + + const filteredData = + selectedContentTypeId !== null + ? toursData.pages.map((group) => ({ + data: { + content: group.data.content.filter( + (item) => item.contentTypeId === selectedContentTypeId, + ), + }, + })) + : toursData.pages; + + return ( +
+ fetchNextPage()} + hasMore={hasNextPage} + loader={ +
+ +
+ }> +
+ {!isLoading && + filteredData.map((group) => ( + + {group?.data.content.map((wishList: TourType) => ( + + ))} + + ))} +
+
+
+ ); +}; diff --git a/src/pages/plan/addPlace/MyLikedListItem.tsx b/src/pages/plan/addPlace/MyLikedListItem.tsx new file mode 100644 index 00000000..c1cdcfa7 --- /dev/null +++ b/src/pages/plan/addPlace/MyLikedListItem.tsx @@ -0,0 +1,57 @@ +import { TourType } from '@/@types/tours.types'; +import { ListCheckBtn } from '@components/common/button/ListSelectBtn'; +import { StarIcon } from '@components/common/icons/Icons'; +import { selectedItemsState } from '@recoil/listItem'; +import { useRecoilState } from 'recoil'; + +interface WishItemProps { + wishList: TourType; +} + +export const MyLikedListItem: React.FC = ({ wishList }) => { + const { + id, + title, + ratingAverage, + reviewCount, + smallThumbnailUrl, + tourAddress, + } = wishList; + + const [selectedItems, setSelectedItems] = useRecoilState(selectedItemsState); + + const handleSelect = () => { + if (selectedItems.includes(id)) { + setSelectedItems(selectedItems.filter((item) => item !== id)); + } else { + setSelectedItems([...selectedItems, id]); + } + }; + + return ( +
+
+ {title} +
+
+
{title}
+
+
+ +
+ + {ratingAverage}({reviewCount}) + + + {tourAddress} + +
+
+ +
+ ); +}; diff --git a/src/pages/plan/addPlace/PlanAddPlace.page.tsx b/src/pages/plan/addPlace/PlanAddPlace.page.tsx new file mode 100644 index 00000000..98002a06 --- /dev/null +++ b/src/pages/plan/addPlace/PlanAddPlace.page.tsx @@ -0,0 +1,32 @@ +import SearchInput from '@components/search/SearchInput'; +import { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { SearchResultForPlan } from './SearchResult'; +import { OurLikedList } from '../OurLikedList'; + +export const PlanAddPlace = () => { + const location = useLocation(); + + const queryParams = new URLSearchParams(location.search); + const searchWordFromQuery = queryParams.get('searchWord'); + + const [searchWord, setSearchWord] = useState(''); + + useEffect(() => { + if (searchWordFromQuery) { + setSearchWord(searchWordFromQuery); + } else { + setSearchWord(''); + } + }, [location, searchWordFromQuery]); + return ( + <> + + {searchWord ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/pages/plan/addPlace/ResultCategoryPlan.tsx b/src/pages/plan/addPlace/ResultCategoryPlan.tsx new file mode 100644 index 00000000..7e3f8706 --- /dev/null +++ b/src/pages/plan/addPlace/ResultCategoryPlan.tsx @@ -0,0 +1,47 @@ +import { ButtonWhite } from '@components/common/button/Button'; +import { TourType } from '@/@types/tours.types'; +import { InfiniteQueryObserverResult } from '@tanstack/react-query'; +import { ResultItemPlan } from './ResultItem'; +import AddToListButton from './AddtoListBtn'; + +interface ResultCategoryProps { + data: TourType[]; + category: string; + fetchNextPage: (() => Promise>) | null; + hasNextPage: boolean; + isFetchingNextPage: boolean; +} + +export const ResultCategoryPlan = ({ + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, +}: ResultCategoryProps) => { + return ( + <> +

ResultCategoryPlan

+ {data.map((item) => ( + + ))} + {hasNextPage && !isFetchingNextPage ? ( + fetchNextPage && fetchNextPage()}> + 더보기 + + ) : isFetchingNextPage ? ( + + Loading... + + ) : ( +
+ )} +
+
+ +
+
+ + ); +}; diff --git a/src/pages/plan/addPlace/ResultItem.tsx b/src/pages/plan/addPlace/ResultItem.tsx new file mode 100644 index 00000000..9ad70dde --- /dev/null +++ b/src/pages/plan/addPlace/ResultItem.tsx @@ -0,0 +1,43 @@ +import { TourType } from '@/@types/tours.types'; +import { ListSelectBtn } from '@components/common/button/ListSelectBtn'; +import { StarIcon } from '@components/common/icons/Icons'; +import { selectedItemsState } from '@recoil/listItem'; +import { useRecoilState } from 'recoil'; + +export const ResultItemPlan = ({ result }: { result: TourType }) => { + const [selectedItems, setSelectedItems] = useRecoilState(selectedItemsState); + const id = result.id; + const handleSelect = () => { + if (selectedItems.includes(id)) { + setSelectedItems(selectedItems.filter((item) => item !== id)); + } else { + setSelectedItems([...selectedItems, id]); + } + }; + return ( +
+
+ {result.title} +
+
+
{result.title}
+
+
+ +
+ + {result.ratingAverage}({result.reviewCount}) + + + {result.tourAddress} + +
+
+ 선택 +
+ ); +}; diff --git a/src/pages/plan/addPlace/SearchResult.tsx b/src/pages/plan/addPlace/SearchResult.tsx new file mode 100644 index 00000000..485f37e8 --- /dev/null +++ b/src/pages/plan/addPlace/SearchResult.tsx @@ -0,0 +1,94 @@ +import { getToursSearch } from '@api/tours'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import ToursCategoryItem from '@components/Tours/ToursCategoryItem'; +import { useEffect, useState } from 'react'; +import { Spinner } from '@components/common/spinner/Spinner'; +import { ResultCategoryPlan } from './ResultCategoryPlan'; + +interface SearchResultProps { + searchWord: string; +} + +export const SearchResultForPlan = ({ searchWord }: SearchResultProps) => { + const categories = ['전체', '숙소', '식당', '관광지']; + const [selectedCategory, setSelectedCategory] = useState('전체'); + useEffect(() => { + console.log(selectedCategory); + }, [selectedCategory]); + + const handleSelectCategory = (category: string) => { + setSelectedCategory(category); + }; + + useEffect(() => { + console.log('searchWord: ' + searchWord); + }, [searchWord]); + console.log(); + + const { + data, + fetchNextPage, + hasNextPage, + isLoading, + isError, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ['searchResults', searchWord, selectedCategory], + queryFn: ({ pageParam = 0 }) => + getToursSearch({ + region: '', + searchWord: searchWord, + category: selectedCategory !== '전체' ? selectedCategory : undefined, + page: pageParam, + size: 20, + }), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + if (!lastPage.data.data.last) { + return allPages.length; + } + return undefined; + }, + enabled: !!searchWord, + retry: 2, + }); + + if (isLoading) { + return ; + } + if (isError) { + console.log('error fetching search result '); + } + + const searchResults = + data?.pages.flatMap((page) => page.data.data.content) || []; + console.log('searchResults', searchResults); + const noResults = searchResults && searchResults.length === 0; + + return ( + <> +
+ {categories.map((category) => ( + + ))} +
+ + {noResults ? ( +
검색결과가 없습니다.
+ ) : ( + + )} + + ); +}; diff --git a/src/pages/plan/planPlaceTrip.page.tsx b/src/pages/plan/planPlaceTrip.page.tsx deleted file mode 100644 index 4ff978c4..00000000 --- a/src/pages/plan/planPlaceTrip.page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const PlanPlaceTrip = () => { - return
여행계획 - 장소 추가 페이지
; -}; - -export default PlanPlaceTrip; diff --git a/src/pages/trip/AddOurList.tsx b/src/pages/trip/AddOurList.tsx new file mode 100644 index 00000000..80bce838 --- /dev/null +++ b/src/pages/trip/AddOurList.tsx @@ -0,0 +1,32 @@ +import SearchInput from '@components/search/SearchInput'; +import { MyLiked } from '@pages/plan/addPlace/MyLiked'; +import { SearchResultForPlan } from '@pages/plan/addPlace/SearchResult'; +import { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; + +export const AddOurList = () => { + const location = useLocation(); + + const queryParams = new URLSearchParams(location.search); + const searchWordFromQuery = queryParams.get('searchWord'); + + const [searchWord, setSearchWord] = useState(''); + + useEffect(() => { + if (searchWordFromQuery) { + setSearchWord(searchWordFromQuery); + } else { + setSearchWord(''); + } + }, [location, searchWordFromQuery]); + return ( +
+ + {searchWord ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/src/recoil/listItem.ts b/src/recoil/listItem.ts new file mode 100644 index 00000000..72eb7456 --- /dev/null +++ b/src/recoil/listItem.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const selectedItemsState = atom({ + key: 'selectedItemsState', + default: [], +}); diff --git a/src/router/socketRouter.tsx b/src/router/socketRouter.tsx index 4503f830..a48c16dc 100644 --- a/src/router/socketRouter.tsx +++ b/src/router/socketRouter.tsx @@ -1,12 +1,13 @@ import { Route, Routes } from 'react-router-dom'; import { useSocket, socketContext } from '@hooks/useSocket'; import PlanTrip from '@pages/plan/planTrip.page'; -import PlanPlaceTrip from '@pages/plan/planPlaceTrip.page'; +import { PlanAddPlace } from '@pages/plan/addPlace/PlanAddPlace.page'; import PlanPlaceSearch from '@pages/plan/planPlaceSearch.page'; import Trip from '@pages/trip/trip.page'; import MainLayout from './routerLayout'; import { useRecoilValue } from 'recoil'; import { tripIdState, visitDateState } from '@recoil/socket'; +import { AddOurList } from '@pages/trip/AddOurList'; const SocketRoutes = () => { const tripId = useRecoilValue(tripIdState); @@ -19,7 +20,7 @@ const SocketRoutes = () => { } /> - } /> + } /> } /> @@ -31,6 +32,7 @@ const SocketRouter = () => { }> } /> + } /> } />