diff --git a/package-lock.json b/package-lock.json index 44fbfae7..3fd906dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,10 +20,12 @@ "react": "^18.1.0", "react-dom": "^18.1.0", "react-icons": "^4.4.0", + "react-kakao-maps-sdk": "^1.1.9", "react-query": "^3.39.1", "react-router-dom": "^6.3.0", "react-scripts": "^5.0.1", "styled-components": "^5.3.5", + "swiper": "^9.4.1", "typescript": "^4.7.2" }, "devDependencies": { @@ -22256,6 +22258,11 @@ "node": ">=8" } }, + "node_modules/kakao.maps.d.ts": { + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/kakao.maps.d.ts/-/kakao.maps.d.ts-0.1.38.tgz", + "integrity": "sha512-ub3ITsp/XfM7OikRvnsQiK6oZgyqVKVvGm9bmChudfDRjFa6xrS2O/bLNs0EyFCQZufVBXLLJK9+T06LOYxNiw==" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -26712,6 +26719,18 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/react-kakao-maps-sdk": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/react-kakao-maps-sdk/-/react-kakao-maps-sdk-1.1.9.tgz", + "integrity": "sha512-9bm4mS7eCRpIQ5tRdd0U0fN9XC10sdkr1Fbh4/y+Eik8eQVPVkguRPwmd8Cn7IuJZyaF4gyjOILdDeDrqcbveg==", + "dependencies": { + "kakao.maps.d.ts": "^0.1.38" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/react-merge-refs": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-1.1.0.tgz", @@ -28862,6 +28881,11 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, + "node_modules/ssr-window": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-4.0.2.tgz", + "integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==" + }, "node_modules/ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -29562,6 +29586,27 @@ "boolbase": "~1.0.0" } }, + "node_modules/swiper": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-9.4.1.tgz", + "integrity": "sha512-1nT2T8EzUpZ0FagEqaN/YAhRj33F2x/lN6cyB0/xoYJDMf8KwTFT3hMOeoB8Tg4o3+P/CKqskP+WX0Df046fqA==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/swiperjs" + }, + { + "type": "open_collective", + "url": "http://opencollective.com/swiper" + } + ], + "dependencies": { + "ssr-window": "^4.0.2" + }, + "engines": { + "node": ">= 4.7.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -48862,6 +48907,11 @@ "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==", "dev": true }, + "kakao.maps.d.ts": { + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/kakao.maps.d.ts/-/kakao.maps.d.ts-0.1.38.tgz", + "integrity": "sha512-ub3ITsp/XfM7OikRvnsQiK6oZgyqVKVvGm9bmChudfDRjFa6xrS2O/bLNs0EyFCQZufVBXLLJK9+T06LOYxNiw==" + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -52100,6 +52150,14 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "react-kakao-maps-sdk": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/react-kakao-maps-sdk/-/react-kakao-maps-sdk-1.1.9.tgz", + "integrity": "sha512-9bm4mS7eCRpIQ5tRdd0U0fN9XC10sdkr1Fbh4/y+Eik8eQVPVkguRPwmd8Cn7IuJZyaF4gyjOILdDeDrqcbveg==", + "requires": { + "kakao.maps.d.ts": "^0.1.38" + } + }, "react-merge-refs": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-1.1.0.tgz", @@ -53775,6 +53833,11 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, + "ssr-window": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-4.0.2.tgz", + "integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==" + }, "ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -54327,6 +54390,14 @@ } } }, + "swiper": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-9.4.1.tgz", + "integrity": "sha512-1nT2T8EzUpZ0FagEqaN/YAhRj33F2x/lN6cyB0/xoYJDMf8KwTFT3hMOeoB8Tg4o3+P/CKqskP+WX0Df046fqA==", + "requires": { + "ssr-window": "^4.0.2" + } + }, "symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index 786a421a..aafbf839 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "react": "^18.1.0", "react-dom": "^18.1.0", "react-icons": "^4.4.0", + "react-kakao-maps-sdk": "^1.1.9", "react-query": "^3.39.1", "react-router-dom": "^6.3.0", "react-scripts": "^5.0.1", "styled-components": "^5.3.5", + "swiper": "^9.4.1", "typescript": "^4.7.2" }, "scripts": { diff --git a/public/index.html b/public/index.html index baf8da57..dbe4329b 100644 --- a/public/index.html +++ b/public/index.html @@ -33,5 +33,9 @@
+ diff --git a/src/api/bookmark/fetchBookmarkList.ts b/src/api/bookmark/fetchBookmarkList.ts index 148c1d14..f1dacc4b 100644 --- a/src/api/bookmark/fetchBookmarkList.ts +++ b/src/api/bookmark/fetchBookmarkList.ts @@ -1,4 +1,4 @@ -import { BookmarkStore } from "types/common/bookmarkTypes"; +import type { BookmarkStore } from "types/common/bookmarkTypes"; import { ACCESS_TOKEN, ENDPOINTS } from "constants/api"; diff --git a/src/asset/campus-pin-icon.svg b/src/asset/campus-pin-icon.svg new file mode 100644 index 00000000..70a7ca23 --- /dev/null +++ b/src/asset/campus-pin-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/asset/clicked-pin-icon.svg b/src/asset/clicked-pin-icon.svg new file mode 100644 index 00000000..df244242 --- /dev/null +++ b/src/asset/clicked-pin-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/asset/index.ts b/src/asset/index.ts index 50478c8c..365f9e18 100644 --- a/src/asset/index.ts +++ b/src/asset/index.ts @@ -5,3 +5,6 @@ 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 PinIcon } from "./pin-icon.svg"; +export { ReactComponent as ClickedPinIcon } from "./clicked-pin-icon.svg"; +export { ReactComponent as CampusPinIcon } from "./campus-pin-icon.svg"; diff --git a/src/asset/pin-icon.svg b/src/asset/pin-icon.svg new file mode 100644 index 00000000..fe5e7100 --- /dev/null +++ b/src/asset/pin-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/common/EventMapMarker/EventMapMarker.tsx b/src/components/common/EventMapMarker/EventMapMarker.tsx new file mode 100644 index 00000000..f585e666 --- /dev/null +++ b/src/components/common/EventMapMarker/EventMapMarker.tsx @@ -0,0 +1,45 @@ +import ClickedPinIcon from "../../../asset/clicked-pin-icon.svg"; +import PinIcon from "../../../asset/pin-icon.svg"; +import { MapMarker } from "react-kakao-maps-sdk"; + +import { theme } from "style/Theme"; + +interface EventMapMarkerProps { + /** 마커를 표시할 위치 - 경도, 위도 */ + markerPosition: { lat: number; lng: number }; + /** 마커가 클릭 여부 */ + isMarkerClicked: boolean; + /** 마커가 클릭되었을 때 발생할 액션 */ + onMarkerClick: () => void; +} + +const MARKER_SIZE = { + CLICKED: 42, + NOT_CLICKED: 36, +} as const; + +function EventMapMarker({ + markerPosition, + isMarkerClicked, + onMarkerClick, +}: EventMapMarkerProps) { + const icon = isMarkerClicked ? ClickedPinIcon : PinIcon; + const size = isMarkerClicked ? MARKER_SIZE.CLICKED : MARKER_SIZE.NOT_CLICKED; + + return ( + + ); +} + +export default EventMapMarker; diff --git a/src/components/common/SlideCarousel/SlideCarousel.tsx b/src/components/common/SlideCarousel/SlideCarousel.tsx new file mode 100644 index 00000000..c3204159 --- /dev/null +++ b/src/components/common/SlideCarousel/SlideCarousel.tsx @@ -0,0 +1,44 @@ +import { MutableRefObject } from "react"; +import { Pagination, Navigation } from "swiper"; +import "swiper/css"; +import "swiper/css"; +import "swiper/css/navigation"; +import "swiper/css/pagination"; +import { Swiper, SwiperRef, SwiperSlide } from "swiper/react"; + +interface SlideCarouselProps { + children: JSX.Element[]; + spaceBetween?: number; + onSlideChange: (index: number) => void; + swiperRef: MutableRefObject; +} + +function SlideCarousel({ + children, + spaceBetween = 8, + onSlideChange, + swiperRef, +}: SlideCarouselProps) { + const handleSlideChange = () => { + const activeSlideIndex = swiperRef.current?.swiper.realIndex ?? 0; + onSlideChange(activeSlideIndex); + }; + + return ( + + {children.map((child) => ( + {child} + ))} + + ); +} + +export default SlideCarousel; diff --git a/src/components/pages/MyPage/BookmarkMapPage/BookmarkMapPage.style.ts b/src/components/pages/MyPage/BookmarkMapPage/BookmarkMapPage.style.ts new file mode 100644 index 00000000..9922cfab --- /dev/null +++ b/src/components/pages/MyPage/BookmarkMapPage/BookmarkMapPage.style.ts @@ -0,0 +1,40 @@ +import styled, { css } from "styled-components"; + +export const HeaderWrapper = styled.header` + display: flex; + justify-content: space-between; + padding: ${({ theme }) => theme.spacer.spacing3}; + margin-bottom: ${({ theme }) => theme.spacer.spacing4}; + background-color: white; +`; + +export const headerStyle = css` + position: absolute; + left: 50%; + font-weight: bold; + transform: translateX(-50%); +`; + +export const MapWrapper = styled.div` + height: calc(100vh - 208px); + + & #react-kakao-maps-sdk-map-container { + width: 100%; + height: 100%; + } +`; + +export const StoreListWrapper = styled.div` + position: absolute; + bottom: ${({ theme }) => theme.spacer.spacing3}; + width: calc(100% - 16px); + z-index: ${({ theme }) => theme.zIndex.overlay}; + + & li { + width: calc(100% - 16px); + margin: ${({ theme }) => theme.spacer.spacing3}; + padding: ${({ theme }) => theme.spacer.spacing3}; + border-radius: ${({ theme }) => theme.borderRadius.small}; + box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.15); + } +`; diff --git a/src/components/pages/MyPage/BookmarkMapPage/BookmarkMapPage.tsx b/src/components/pages/MyPage/BookmarkMapPage/BookmarkMapPage.tsx new file mode 100644 index 00000000..390784c1 --- /dev/null +++ b/src/components/pages/MyPage/BookmarkMapPage/BookmarkMapPage.tsx @@ -0,0 +1,122 @@ +import CampusPinIcon from "../../../../asset/campus-pin-icon.svg"; +import * as S from "./BookmarkMapPage.style"; +import { useContext, useEffect, useState } from "react"; +import { Map, MapMarker } from "react-kakao-maps-sdk"; +import { useQuery } from "react-query"; +import { useNavigate } from "react-router-dom"; +import type { Campus } from "types/common"; + +import { NETWORK } from "constants/api"; +import { CAMPUS_AREA_CENTER_POSITION, CAMPUS_POSITION } from "constants/campus"; + +import { LeftIcon } from "asset"; + +import { campusContext } from "context/CampusContextProvider"; + +import { Position, useMap } from "hooks/useMap"; +import { useSlideCarousel } from "hooks/useSlideCarousel"; + +import fetchBookmarkList from "api/bookmark/fetchBookmarkList"; + +import ErrorImage from "components/common/ErrorImage/ErrorImage"; +import EventMapMarker from "components/common/EventMapMarker/EventMapMarker"; +import SlideCarousel from "components/common/SlideCarousel/SlideCarousel"; +import Spinner from "components/common/Spinner/Spinner"; +import StoreListItem from "components/common/StoreListItem/StoreListItem"; +import Text from "components/common/Text/Text"; + +function BookmarkMapPage() { + const navigate = useNavigate(); + const campusName = useContext(campusContext); + + const { data, isLoading, isFetching, isError, error } = useQuery( + "bookmarkStore", + () => fetchBookmarkList(), + { + retry: NETWORK.RETRY_COUNT, + refetchOnWindowFocus: false, + } + ); + + const bookmarkedStores = data ?? []; + + const { center, positions, setCenter } = useMap( + bookmarkedStores, + CAMPUS_AREA_CENTER_POSITION[campusName!] + ); + const [selectedMarker, setSelectedMarker] = useState(); + const { swiperRef, handleSlideToPosition } = useSlideCarousel(); + + useEffect(() => { + if (positions) setSelectedMarker(positions[0]); + }, [positions]); + + const handleStoreChange = (id: number) => { + const information = positions.find((store) => store.id === id)!; + setSelectedMarker(information); + setCenter(information.latlng); + }; + + const handleInformationSlide = (index: number) => { + const { id } = bookmarkedStores[index]; + handleStoreChange(id); + }; + + const handlePinClick = (id: number) => { + const index = positions.findIndex((position) => position.id === id); + handleSlideToPosition(index); + handleStoreChange(id); + }; + + return ( + <> + + navigate(-1)} /> + 나의 맛집 지도 + + + {(isLoading || isFetching) && } + {isError && error instanceof Error && ( + + )} + + + {positions.map((position) => ( + handlePinClick(position.id)} + /> + ))} + + + + + {bookmarkedStores.map((store) => ( + + ))} + + + + ); +} + +export default BookmarkMapPage; diff --git a/src/components/pages/index.tsx b/src/components/pages/index.tsx index b9ddf26f..0cafe692 100644 --- a/src/components/pages/index.tsx +++ b/src/components/pages/index.tsx @@ -7,4 +7,5 @@ export { default as StoreDetailPage } from "components/pages/StoreDetailPage/Sto export { default as StoreDemandPage } from "components/pages/StoreDemandPage/StoreDemandPage"; export { default as MyPage } from "components/pages/MyPage/MyPage"; export { default as BookmarkListPage } from "components/pages/MyPage/BookmarkListPage/BookmarkListPage"; +export { default as BookmarkMapPage } from "components/pages/MypagePage/BookmarkMapPage/BookmarkMapPage"; export { default as MyReviewListPage } from "components/pages/MyPage/MyReviewListPage/MyReviewListPage"; diff --git a/src/constants/campus.tsx b/src/constants/campus.tsx index 4d2bfbba..5aa93c5e 100644 --- a/src/constants/campus.tsx +++ b/src/constants/campus.tsx @@ -12,3 +12,25 @@ export const getOtherCampus = (currentCampus: Campus) => currentCampus === CAMPUS.JAMSIL.name ? CAMPUS.SEOULLEUNG.name : CAMPUS.JAMSIL.name; + +export const CAMPUS_AREA_CENTER_POSITION = { + [CAMPUS.JAMSIL.name]: { + lat: 37.51520119987365, + lng: 127.1030398680359, + }, + [CAMPUS.SEOULLEUNG.name]: { + lat: 37.5054936224827, + lng: 127.050863180985, + }, +}; + +export const CAMPUS_POSITION = { + [CAMPUS.JAMSIL.name]: { + lat: 37.5152535228382, + lng: 127.103068896795, + }, + [CAMPUS.SEOULLEUNG.name]: { + lat: 37.5054936224827, + lng: 127.050863180985, + }, +}; diff --git a/src/constants/routes.tsx b/src/constants/routes.tsx index 4c61d85b..1f7eacfa 100644 --- a/src/constants/routes.tsx +++ b/src/constants/routes.tsx @@ -8,6 +8,7 @@ import { StoreDemandPage, MyPage, BookmarkListPage, + BookmarkMapPage, MyReviewListPage, } from "components/pages"; @@ -20,6 +21,7 @@ export const PATHNAME = { STORE_DEMAND: "store-demand", MY_PAGE: "/my-page", BOOKMARK_LIST_PAGE: "/my-page/bookmark", + BOOKMARK_MAP_PAGE: "/my-page/bookmark/map", MY_REVIEWS: "/my-page/reviews", WILD_CARD: "*", } as const; @@ -47,6 +49,10 @@ const MAIN_ROUTES = { path: PATHNAME.BOOKMARK_LIST_PAGE, element: , }, + BOOKMARK_MAP_PAGE: { + path: PATHNAME.BOOKMARK_MAP_PAGE, + element: , + }, MY_REVIEWS: { path: PATHNAME.MY_REVIEWS, element: , diff --git a/src/hooks/useMap.ts b/src/hooks/useMap.ts new file mode 100644 index 00000000..f33cdc82 --- /dev/null +++ b/src/hooks/useMap.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import type { BookmarkStore, Store } from "types/common"; + +export interface Position { + id: number; + latlng: { + lat: number; + lng: number; + }; + addressName: string; +} + +/** + * @param {Store[] | BookmarkStore[]} stores - 지도에 표시할 상점 목록 + * @param {Position["latlng"]} centerPosition - 지도의 중심 위치를 나타내는 위도와 경도 + * + * @returns {Object} center와 positions을 반환하며, setCenter 메소드를 통해 중심 위치를 업데이트 할 수 있다. + */ + +export const useMap = ( + stores: Store[] | BookmarkStore[], + centerPosition: Position["latlng"] +) => { + const [center, setCenter] = useState(centerPosition); + const [positions, setPositions] = useState([]); + + const getPosition = (store: Store | BookmarkStore): Promise => { + const geocoder = new kakao.maps.services.Geocoder(); + + return new Promise((resolve, reject) => { + geocoder.addressSearch(store.address, (result, status) => { + if (status === kakao.maps.services.Status.OK) { + resolve({ + id: store.id, + latlng: { + lat: Number(result[0].y), + lng: Number(result[0].x), + }, + addressName: result[0].address_name, + }); + } else { + reject(new Error("없는 주소입니다.")); + } + }); + }); + }; + + const getStorePositions = () => { + if (!stores) { + setPositions([]); + + return; + } + + const getAddressPromises = stores.map(getPosition); + + Promise.all(getAddressPromises).then((results) => { + setPositions([...results]); + }); + }; + + useEffect(() => { + getStorePositions(); + }, [stores]); + + return { center, positions, setCenter }; +}; diff --git a/src/hooks/useSlideCarousel.ts b/src/hooks/useSlideCarousel.ts new file mode 100644 index 00000000..3d753548 --- /dev/null +++ b/src/hooks/useSlideCarousel.ts @@ -0,0 +1,12 @@ +import { useRef } from "react"; +import { SwiperRef } from "swiper/react"; + +export const useSlideCarousel = () => { + const swiperRef = useRef(null); + + const handleSlideToPosition = (index: number) => { + swiperRef.current?.swiper.slideToLoop(index, 800, true); + }; + + return { swiperRef, handleSlideToPosition }; +}; diff --git a/src/mock/handlers/bookmarkHandler.ts b/src/mock/handlers/bookmarkHandler.ts index 96c39810..f3a7e190 100644 --- a/src/mock/handlers/bookmarkHandler.ts +++ b/src/mock/handlers/bookmarkHandler.ts @@ -10,6 +10,7 @@ export const bookmarkHandler = [ rest.get(`${API_BASE_URL}/restaurants/bookmarks`, (req, res, ctx) => { return res(ctx.status(200), ctx.json(bookmarkedStores)); }), + rest.post( `${API_BASE_URL}/restaurants/:restaurantId/bookmarks`, (req, res, ctx) => { diff --git a/src/mock/handlers/index.ts b/src/mock/handlers/index.ts index 37ba3345..5a8c169c 100644 --- a/src/mock/handlers/index.ts +++ b/src/mock/handlers/index.ts @@ -16,5 +16,4 @@ export { reviewHandler, storeDemandHandler as storeRequestHandler, imageHandler, - bookmarkHandler, }; diff --git a/src/mock/userData.ts b/src/mock/userData.ts index 7250e5fe..86b9c7b2 100644 --- a/src/mock/userData.ts +++ b/src/mock/userData.ts @@ -1,3 +1,5 @@ +import type { BookmarkStore } from "types/common/bookmarkTypes"; + import type { UserReview } from "types/common"; export const userProfile = { @@ -8,7 +10,7 @@ export const userProfile = { averageRating: 3.0, }; -export const bookmarkedStores = [ +export const bookmarkedStores: BookmarkStore[] = [ { id: 1, name: "냠냠 치킨", diff --git a/tsconfig.json b/tsconfig.json index 5452a6dc..cf9aabb7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "baseUrl": "src" + "baseUrl": "src", + "types": ["kakao.maps.d.ts"] }, "include": ["src"] }