diff --git a/index.html b/index.html index e68e052..bbb0e16 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,10 @@ + Gieoghaebom diff --git a/package-lock.json b/package-lock.json index c7168f7..121832b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", + "react-kakao-maps-sdk": "^1.1.27", "react-lottie": "^1.2.4", "react-router-dom": "^6.22.3", "react-slick": "^0.30.2", @@ -6215,6 +6216,12 @@ "node": ">=0.10.0" } }, + "node_modules/kakao.maps.d.ts": { + "version": "0.1.40", + "resolved": "https://registry.npmjs.org/kakao.maps.d.ts/-/kakao.maps.d.ts-0.1.40.tgz", + "integrity": "sha512-nX69MB1ok04epe3OqS+/tEeWBbU31GSQbvDPJmQRRltzzqn6t4jBsO5v1nzalUjCKzwcH2CptOc767NZ7Hbu3g==", + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7211,6 +7218,20 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-kakao-maps-sdk": { + "version": "1.1.27", + "resolved": "https://registry.npmjs.org/react-kakao-maps-sdk/-/react-kakao-maps-sdk-1.1.27.tgz", + "integrity": "sha512-1EwYkYsjTDRFqysKStDasFMrFTXcLx2AyRlqMoWD7ONWhRqpjx9M874hkhEEHrnypP2eSIhhDLe0EiSKp3bd2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.15", + "kakao.maps.d.ts": "^0.1.39" + }, + "peerDependencies": { + "react": "^16.8 || ^17 || ^18", + "react-dom": "^16.8 || ^17 || ^18" + } + }, "node_modules/react-lottie": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/react-lottie/-/react-lottie-1.2.4.tgz", diff --git a/package.json b/package.json index 148e97c..89b5fbf 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", + "react-kakao-maps-sdk": "^1.1.27", "react-lottie": "^1.2.4", "react-router-dom": "^6.22.3", "react-slick": "^0.30.2", diff --git a/src/apis/instance.ts b/src/apis/instance.ts index 80b7852..5b1ac27 100644 --- a/src/apis/instance.ts +++ b/src/apis/instance.ts @@ -2,10 +2,15 @@ import axios, { AxiosInstance } from "axios"; export const imgInstance: AxiosInstance = axios.create({ baseURL: `https://picsum.photos/v2`, - timeout: 5000 + timeout: 1000 }); export const instance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 50000 }); + +export const flaskInstance = axios.create({ + baseURL: import.meta.env.VITE_API_FLASK_BASE_URL, + timeout: 50000 +}); diff --git a/src/apis/postAroundSeniorCenter.ts b/src/apis/postAroundSeniorCenter.ts new file mode 100644 index 0000000..762041b --- /dev/null +++ b/src/apis/postAroundSeniorCenter.ts @@ -0,0 +1,29 @@ +import { MapProps } from "@components/KakaoMap/KakaoMap.types"; +import { flaskInstance } from "./instance"; + +const postAroundSeniorCenter = async (latitude: number, longitude: number): Promise => { + const postAroundSeniorCenterURL = "/locations"; + + try { + const accessToken = sessionStorage.getItem("accessToken"); + + const requestBody = { + latitude, + longitude + }; + + const config = { + headers: { + Authorization: `Bearer ${accessToken}` + } + }; + + const response = await flaskInstance.post(postAroundSeniorCenterURL, requestBody, config); + + return response.data; + } catch (error) { + throw error; + } +}; + +export default postAroundSeniorCenter; diff --git a/src/apis/postSeniorCenterList.ts b/src/apis/postSeniorCenterList.ts new file mode 100644 index 0000000..bb3a281 --- /dev/null +++ b/src/apis/postSeniorCenterList.ts @@ -0,0 +1,29 @@ +import { MapProps } from "@components/KakaoMap/KakaoMap.types"; +import { flaskInstance } from "./instance"; + +const postSeniorCenterList = async (page: number, keyword: string): Promise => { + const postSeniorCenterListURL = "/search"; + + try { + const accessToken = sessionStorage.getItem("accessToken"); + + const requestBody = { + keyword, + page + }; + + const config = { + headers: { + Authorization: `Bearer ${accessToken}` + } + }; + + const response = await flaskInstance.post(postSeniorCenterListURL, requestBody, config); + + return response.data; + } catch (error) { + throw error; + } +}; + +export default postSeniorCenterList; diff --git a/src/assets/icons/backArrow.svg b/src/assets/icons/backArrow.svg new file mode 100644 index 0000000..9bdea57 --- /dev/null +++ b/src/assets/icons/backArrow.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/listIcon.svg b/src/assets/icons/listIcon.svg new file mode 100644 index 0000000..70e700d --- /dev/null +++ b/src/assets/icons/listIcon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/assets/icons/mapIcon.svg b/src/assets/icons/mapIcon.svg new file mode 100644 index 0000000..997b219 --- /dev/null +++ b/src/assets/icons/mapIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/phoneIcon.svg b/src/assets/icons/phoneIcon.svg new file mode 100644 index 0000000..0b3f1fa --- /dev/null +++ b/src/assets/icons/phoneIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/positionIcon.svg b/src/assets/icons/positionIcon.svg new file mode 100644 index 0000000..20e23cc --- /dev/null +++ b/src/assets/icons/positionIcon.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/retryIcon.svg b/src/assets/icons/retryIcon.svg new file mode 100644 index 0000000..8a39e16 --- /dev/null +++ b/src/assets/icons/retryIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/sadIcon.svg b/src/assets/icons/sadIcon.svg new file mode 100644 index 0000000..e990775 --- /dev/null +++ b/src/assets/icons/sadIcon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/assets/icons/searchIcon.svg b/src/assets/icons/searchIcon.svg new file mode 100644 index 0000000..38de032 --- /dev/null +++ b/src/assets/icons/searchIcon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/icons/smileIcon.svg b/src/assets/icons/smileIcon.svg new file mode 100644 index 0000000..57d61d5 --- /dev/null +++ b/src/assets/icons/smileIcon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/assets/icons/timerIcon.svg b/src/assets/icons/timerIcon.svg new file mode 100644 index 0000000..087b6eb --- /dev/null +++ b/src/assets/icons/timerIcon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/icons/whiteListIcon.svg b/src/assets/icons/whiteListIcon.svg new file mode 100644 index 0000000..6adea16 --- /dev/null +++ b/src/assets/icons/whiteListIcon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/assets/icons/whiteMapIcon.svg b/src/assets/icons/whiteMapIcon.svg new file mode 100644 index 0000000..516a862 --- /dev/null +++ b/src/assets/icons/whiteMapIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/KakaoMap/KakaoMap.styles.ts b/src/components/KakaoMap/KakaoMap.styles.ts new file mode 100644 index 0000000..c54b8b9 --- /dev/null +++ b/src/components/KakaoMap/KakaoMap.styles.ts @@ -0,0 +1,90 @@ +import styled from "@emotion/styled"; + +export const Pin = styled.button` + min-width: 70px; + max-width: 106px; + height: 38px; + flex-shrink: 0; + + position: relative; + display: flex; + justify-content: center; + align-items: center; + padding: 2px 8px; + margin: 0 16px; + + ${({ theme }) => theme.text.detail2_bold}; + color: ${({ theme }) => theme.colors.gray[600]}; + + border-radius: 71px; + background-color: #fff; + + transition: all 0.2s ease; + + border: 1px solid #00b207; + filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25)); + + &::after { + content: ""; + position: absolute; + bottom: -14px; + left: 50%; + transform: translateX(-50%); + border-top: 14px solid #fff; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 1px solid transparent; + } + + &[data-selected="true"] { + color: #fff; + + border: 1px solid #fff; + background-color: #00b207; + + &::after { + border-top: 14px solid #00b207; + } + } +`; + +export const SearchButton = styled.button` + position: absolute; + top: 100px; + left: 50%; + transform: translateX(-50%); + + display: flex; + padding: 6px 12px; + align-items: center; + gap: 4px; + + border-radius: 50px; + background: #fff; + box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.15), 0px 1px 2px 1px rgba(0, 0, 0, 0.15); + + z-index: 1001; +`; + +export const SearchBtnTxt = styled.p` + color: #176d1b; + + ${({ theme }) => theme.text.detail2_reg}; +`; + +export const clustererStyles = { + display: "flex", + justifyContent: "center", + alignItems: "center", + width: "80px", + height: "80px", + padding: "10px", + border: "2px solid #fff", + borderRadius: "50%", + background: + "linear-gradient(102deg, rgba(58, 200, 244, 0.90) 10.91%, rgba(94, 155, 243, 0.90) 89.69%)", + boxShadow: "0px 2px 18px 3px rgba(0, 0, 0, 0.10)", + fontSize: "20px", + fontWeight: 600, + color: "#fff" +}; diff --git a/src/components/KakaoMap/KakaoMap.tsx b/src/components/KakaoMap/KakaoMap.tsx new file mode 100644 index 0000000..883d2ba --- /dev/null +++ b/src/components/KakaoMap/KakaoMap.tsx @@ -0,0 +1,119 @@ +import { useState, useRef, useEffect } from "react"; +import * as S from "./KakaoMap.styles"; +import { Map, MarkerClusterer } from "react-kakao-maps-sdk"; +import { ProductsMarkers } from "../ProductMarker/ProductMarker"; +import { PositionMarker } from "../PositionMarker/PositionMarker"; +import { MapCenter, UserPositionType, ProductType, MapProps } from "./KakaoMap.types"; +import ProductCardForMap from "../ProductCard/ProductCardForMap"; +import Search from "../Search/Search"; +import SearchBar from "@components/SearchBar/SearchBar"; +import { useLocation } from "react-router-dom"; + +const KakaoMap = ({ result, _setMapCenter }: MapProps) => { + if (!result) return null; + + const query = new URLSearchParams(useLocation().search); + const latitude = query.get("latitude"); + const longitude = query.get("longitude"); + const id = query.get("id"); + + const [selectedProductId, setSelectedProductId] = useState( + result.length > 0 ? result[0].id : 0 + ); + const [mapCenter, setMapCenter] = useState({ + lat: result.length > 0 ? result[0].latitude : 33.450701, + lng: result.length > 0 ? result[0].longitude : 126.570667 + }); + const [userPosition, setUserPosition] = useState({ + lat: null, + lng: null, + errorMessage: "", + isLoading: true + }); + + const selectedProduct = result.find((product: ProductType) => product.id === selectedProductId); + + const mapRef = useRef(null); + + const initPosition = () => { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + const newPosition = { + lat: position.coords.latitude, + lng: position.coords.longitude + }; + console.log("curr position", newPosition); + setMapCenter(newPosition); + setUserPosition((prev) => ({ + ...prev, + lat: newPosition.lat, + lng: newPosition.lng, + isLoading: false + })); + mapRef.current?.setLevel(5); + mapRef.current?.setCenter( + new kakao.maps.LatLng(position.coords.latitude, position.coords.longitude) + ); + }, + (err) => { + setUserPosition((prev) => ({ + ...prev, + errorMessage: err.message, + isLoading: false + })); + } + ); + } else { + setUserPosition((prev) => ({ + ...prev, + errorMessage: "현재 위치를 사용할 수 없습니다.", + isLoading: false + })); + } + }; + + useEffect(() => { + if (!latitude && !longitude) initPosition(); + else { + setMapCenter({ lat: Number(latitude), lng: Number(longitude) }); + } + }, []); + + useEffect(() => { + if (id) setSelectedProductId(Number(id)); + else if (result.length > 0) setSelectedProductId(result[0].id); + }, [id, result]); + + return ( + <> + + {userPosition.lat && userPosition.lng ? : null} + {result.length > 0 && ( + + + + )} + + + + + + ); +}; + +export default KakaoMap; diff --git a/src/components/KakaoMap/KakaoMap.types.ts b/src/components/KakaoMap/KakaoMap.types.ts new file mode 100644 index 0000000..f6aaba7 --- /dev/null +++ b/src/components/KakaoMap/KakaoMap.types.ts @@ -0,0 +1,35 @@ +export interface UserPositionType { + lat: number | null; + lng: number | null; + errorMessage: string; + isLoading: boolean; +} + +export interface MapCenter { + lat: number; + lng: number; +} + +export interface UserPositionType { + lat: number | null; + lng: number | null; + errorMessage: string; + isLoading: boolean; +} + +export interface ProductType { + facility_name: string; + id: number; + latitude: number; + longitude: number; + number_addr: string | null; + operation_time: string; + phone_number: string; + road_name_addr: string | null; +} + +export interface MapProps { + result: ProductType[] | undefined; + total_count: number | undefined; + _setMapCenter?: React.Dispatch>; +} diff --git a/src/components/MapToggleButton/MapToggleButton.styles.ts b/src/components/MapToggleButton/MapToggleButton.styles.ts new file mode 100644 index 0000000..20b2f8c --- /dev/null +++ b/src/components/MapToggleButton/MapToggleButton.styles.ts @@ -0,0 +1,34 @@ +import styled from "@emotion/styled"; + +interface ToggleOptionProps { + isActive: boolean; +} + +export const ToggleButtonContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + + width: 88px; + height: 44px; + flex-shrink: 0; + + border-radius: 12px; + border: 1px solid ${({ theme }) => theme.colors.gray[300]}; + background: #fff; +`; + +export const ToggleOption = styled.div` + background-color: ${({ isActive }) => (isActive ? "#32CD32" : "#fff")}; + cursor: pointer; + + display: flex; + justify-content: center; + align-items: center; + width: 42px; + height: 38px; + flex-shrink: 0; + + border: none; + border-radius: 10px; +`; diff --git a/src/components/MapToggleButton/MapToggleButton.tsx b/src/components/MapToggleButton/MapToggleButton.tsx new file mode 100644 index 0000000..d07a9d3 --- /dev/null +++ b/src/components/MapToggleButton/MapToggleButton.tsx @@ -0,0 +1,30 @@ +import * as S from "./MapToggleButton.styles"; +import ListIcon from "@assets/icons/listIcon.svg?react"; +import WhiteListIcon from "@assets/icons/whiteListIcon.svg?react"; +import MapIcon from "@assets/icons/mapIcon.svg?react"; +import WhiteMapIcon from "@assets/icons/whiteMapIcon.svg?react"; +import { useNavigate, useLocation } from "react-router-dom"; + +const ToggleButton = () => { + const navigate = useNavigate(); + const location = useLocation(); + + return ( + + navigate("/seniorCenterList")} + > + {location.pathname === "/seniorCenterList" ? : } + + navigate("/seniorCenterMap")} + > + {location.pathname === "/seniorCenterMap" ? : } + + + ); +}; + +export default ToggleButton; diff --git a/src/components/PositionMarker/PositionMarker.tsx b/src/components/PositionMarker/PositionMarker.tsx new file mode 100644 index 0000000..9dc3865 --- /dev/null +++ b/src/components/PositionMarker/PositionMarker.tsx @@ -0,0 +1,21 @@ +import { CustomOverlayMap } from "react-kakao-maps-sdk"; +import { UserPositionType } from "../KakaoMap/KakaoMap.types"; +import PositionIcon from "@assets/icons/positionIcon.svg?react"; + +interface PositionMarkerProps { + position: UserPositionType; +} + +export const PositionMarker = ({ position: { lat, lng, isLoading } }: PositionMarkerProps) => { + if (lat && lng) { + return ( +
+ {!isLoading && ( + + + + )} +
+ ); + } +}; diff --git a/src/components/ProductCard/ProductCard.styles.ts b/src/components/ProductCard/ProductCard.styles.ts new file mode 100644 index 0000000..f81bf47 --- /dev/null +++ b/src/components/ProductCard/ProductCard.styles.ts @@ -0,0 +1,107 @@ +import styled from "@emotion/styled"; + +export const CardContainer = styled.div` + position: absolute; + bottom: 100px; + left: 50%; + transform: translateX(-50%); + z-index: 1001; + width: calc(100% - 32px); +`; + +export const CardWrapper = styled.div` + position: relative; + height: 167px; + flex-shrink: 0; + + background: #fff; + border-bottom: 1px solid ${({ theme }) => theme.colors.gray[200]}; + + cursor: pointer; +`; + +export const MapCardWrapper = styled.div` + position: relative; + height: 167px; + flex-shrink: 0; + + border-radius: 20px; + background: #fff; + box-shadow: 0px 0px 5px 3px rgba(0, 0, 0, 0.1); + + padding: 16px; +`; + +export const ProductTitle = styled.p` + color: #151515; + + ${({ theme }) => theme.text.heading4}; +`; + +export const AddressWrapper = styled.div` + margin-top: 4px; +`; + +export const Address = styled.p` + color: ${({ theme }) => theme.colors.gray[700]}; + + ${({ theme }) => theme.text.body2_reg}; +`; + +export const SubAddress = styled.p` + color: ${({ theme }) => theme.colors.gray[500]}; + + ${({ theme }) => theme.text.detail1_reg}; +`; + +export const InfoWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 11px; +`; + +export const InfoItem = styled.div` + display: flex; + gap: 8px; + justify-content: flex-start; + align-items: center; +`; + +export const InfoTitle = styled.p` + color: #151515; + + ${({ theme }) => theme.text.body1_bold}; +`; + +export const InfoContent = styled.p` + color: #151515; + + ${({ theme }) => theme.text.body1_reg}; +`; + +export const FriendBtn = styled.button` + display: flex; + width: 102px; + padding: 4px 12px; + justify-content: center; + align-items: center; + gap: 4px; + + border-radius: 8px; + background: #00b207; + + position: absolute; + top: 16px; + right: 16px; +`; + +export const FriendBtnText = styled.p` + color: #fff; + ${({ theme }) => theme.text.body1_reg}; +`; + +export const FriendBtnBoldTxt = styled.p` + color: #fff; + ${({ theme }) => theme.text.body1_bold}; +`; diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 0000000..010844d --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,52 @@ +import * as S from "./ProductCard.styles"; +import TimerIcon from "@assets/icons/timerIcon.svg?react"; +import PhoneIcon from "@assets/icons/phoneIcon.svg?react"; +import { ProductType } from "@components/KakaoMap/KakaoMap.types"; +import { useNavigate } from "react-router-dom"; + +interface ProductCardProps { + selectedProduct: ProductType; +} + +const ProductCardForMap = ({ selectedProduct }: ProductCardProps) => { + const { + id, + facility_name, + number_addr, + operation_time, + phone_number, + road_name_addr, + latitude, + longitude + } = selectedProduct; + const navigate = useNavigate(); + + return ( + + navigate(`/seniorCenterMap?latitude=${latitude}&longitude=${longitude}&id=${id}`) + } + > + {facility_name} + + {number_addr} + {road_name_addr} + + + + + + 운영시간 + {operation_time} + + + + 전화번호 + {phone_number} + + + + ); +}; + +export default ProductCardForMap; diff --git a/src/components/ProductCard/ProductCardForMap.tsx b/src/components/ProductCard/ProductCardForMap.tsx new file mode 100644 index 0000000..57a54b5 --- /dev/null +++ b/src/components/ProductCard/ProductCardForMap.tsx @@ -0,0 +1,45 @@ +import * as S from "./ProductCard.styles"; +import TimerIcon from "@assets/icons/timerIcon.svg?react"; +import PhoneIcon from "@assets/icons/phoneIcon.svg?react"; +import { ProductType } from "@components/KakaoMap/KakaoMap.types"; + +interface ProductCardForMapProps { + selectedProduct: ProductType; +} + +const ProductCardForMap = ({ selectedProduct }: ProductCardForMapProps) => { + const { facility_name, number_addr, operation_time, phone_number, road_name_addr } = + selectedProduct; + + return ( + + + {facility_name} + + {number_addr} + {road_name_addr} + + + + + + 운영시간 + {operation_time} + + + + 전화번호 + {phone_number} + + + + + 내 친구 + 4명 + + + + ); +}; + +export default ProductCardForMap; diff --git a/src/components/ProductMarker/ProductMarker.tsx b/src/components/ProductMarker/ProductMarker.tsx new file mode 100644 index 0000000..33854ee --- /dev/null +++ b/src/components/ProductMarker/ProductMarker.tsx @@ -0,0 +1,35 @@ +import { Dispatch, SetStateAction } from "react"; +import * as S from "../KakaoMap/KakaoMap.styles"; +import { CustomOverlayMap, useMap } from "react-kakao-maps-sdk"; +import { ProductType } from "../KakaoMap/KakaoMap.types"; + +interface ProductMarkersProps { + products: ProductType[]; + selectedProductId: number; + setSelectedProductId: Dispatch>; +} + +export const ProductsMarkers = ({ + products, + selectedProductId, + setSelectedProductId +}: ProductMarkersProps) => { + const map = useMap(); + + const handleSelect = (product: ProductType) => { + const { latitude, longitude } = product; + setSelectedProductId(product.id); + map.panTo(new kakao.maps.LatLng(latitude, longitude)); + }; + + return products.map((product) => ( + + handleSelect(product)} + > + {product.facility_name.slice(0, 7) + "..."} + + + )); +}; diff --git a/src/components/Search/Search.tsx b/src/components/Search/Search.tsx new file mode 100644 index 0000000..ee4dcb0 --- /dev/null +++ b/src/components/Search/Search.tsx @@ -0,0 +1,46 @@ +import useSearch from "@hooks/useSearch"; +import * as S from "../KakaoMap/KakaoMap.styles"; +import { useSearchParams } from "react-router-dom"; +import { MapCenter } from "@components/KakaoMap/KakaoMap.types"; +import RetryIcon from "@assets/icons/retryIcon.svg?react"; +import { isRefetched } from "@stores/searchStore"; +import { useRecoilState } from "recoil"; + +interface SearchProps { + setMapCenter: React.Dispatch> | undefined; +} + +const Search = ({ setMapCenter }: SearchProps) => { + const { position, isMapMoved, setMapMoved } = useSearch(); + const [searchParams, setSearchParams] = useSearchParams(); + const [, setIsRefetched] = useRecoilState(isRefetched); + + const handleSearch = () => { + if (!position) return; + + searchParams.set("swx", position.smallX.toString()); + searchParams.set("swy", position.smallY.toString()); + searchParams.set("nex", position.bigX.toString()); + searchParams.set("ney", position.bigY.toString()); + setSearchParams(searchParams); + setMapMoved(false); + if (setMapCenter) { + setMapCenter({ + lat: (position.smallX + position.bigX) / 2, + lng: (position.smallY + position.bigY) / 2 + }); + setIsRefetched(true); + } + }; + + return ( + isMapMoved && ( + handleSearch()}> + + 이 위치에서 경로당 찾기 + + ) + ); +}; + +export default Search; diff --git a/src/components/SearchBar/SearchBar.styles.ts b/src/components/SearchBar/SearchBar.styles.ts new file mode 100644 index 0000000..451f6fb --- /dev/null +++ b/src/components/SearchBar/SearchBar.styles.ts @@ -0,0 +1,41 @@ +import styled from "@emotion/styled"; + +export const SearchBarContainer = styled.div` + width: 100%; + position: absolute; + top: 33px; + left: 50%; + transform: translateX(-50%); + z-index: 1001; + + display: flex; + padding: 0 16px; + gap: 5px; +`; + +export const InputWrapper = styled.div` + width: calc(100% - 88px); + height: 44px; + display: flex; + flex-direction: row; + align-items: center; + flex-shrink: 0; + + border-radius: 12px; + border: 1px solid ${({ theme }) => theme.colors.gray[300]}; + background: #fff; + + gap: 3px; + padding: 0 16px; +`; + +export const Input = styled.input` + width: 100%; + + border: none; + border-radius: 12px; + + color: ${({ theme }) => theme.colors.gray[400]}; + + ${({ theme }) => theme.text.body1_bold}; +`; diff --git a/src/components/SearchBar/SearchBar.tsx b/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000..bff3e94 --- /dev/null +++ b/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,23 @@ +import * as S from "./SearchBar.styles"; +import SearchIcon from "@assets/icons/searchIcon.svg?react"; +import MapToggleButton from "@components/MapToggleButton/MapToggleButton"; +import { useNavigate } from "react-router-dom"; + +const SearchBar = () => { + const navigate = useNavigate(); + + return ( + + + + navigate("/seniorCenterSearch")} + /> + + + + ); +}; + +export default SearchBar; diff --git a/src/components/Spinner/Spinnter.styles.ts b/src/components/Spinner/Spinnter.styles.ts index 3b04c1a..56acc62 100644 --- a/src/components/Spinner/Spinnter.styles.ts +++ b/src/components/Spinner/Spinnter.styles.ts @@ -6,4 +6,6 @@ export const SpinnerWrapper = styled.div` align-items: center; height: 100%; width: 100%; + + z-index: 10000; `; diff --git a/src/hooks/useAroundSeniorCenter.tsx b/src/hooks/useAroundSeniorCenter.tsx new file mode 100644 index 0000000..89a9a88 --- /dev/null +++ b/src/hooks/useAroundSeniorCenter.tsx @@ -0,0 +1,14 @@ +import postAroundSeniorCenter from "@apis/postAroundSeniorCenter"; +import { useQuery } from "@tanstack/react-query"; + +const useAroundSeniorCenter = (latitude: number, longitude: number) => { + return useQuery({ + queryKey: ["aroundSeniorCenter"], + queryFn: async () => { + return await postAroundSeniorCenter(latitude, longitude); + }, + enabled: false + }); +}; + +export default useAroundSeniorCenter; diff --git a/src/hooks/useIntersect.tsx b/src/hooks/useIntersect.tsx new file mode 100644 index 0000000..9c716be --- /dev/null +++ b/src/hooks/useIntersect.tsx @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useRef } from "react"; + +type IntersectHandler = (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void; + +const useIntersect = ( + onIntersect: IntersectHandler, + options?: IntersectionObserverInit +) => { + const ref = useRef(null); + const callback = useCallback( + (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + onIntersect(entry, observer); + } + }); + }, + [onIntersect] + ); + + useEffect(() => { + if (!ref.current) return; + const observer = new IntersectionObserver(callback, options); + observer.observe(ref.current); + return () => observer.disconnect(); + }, [ref, options, callback]); + + return ref; +}; +export default useIntersect; diff --git a/src/hooks/useSearch.tsx b/src/hooks/useSearch.tsx new file mode 100644 index 0000000..3763f59 --- /dev/null +++ b/src/hooks/useSearch.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import { useMap } from "react-kakao-maps-sdk"; + +interface Position { + smallX: number; + smallY: number; + bigX: number; + bigY: number; +} + +const useSearch = () => { + const [isMapMoved, setMapMoved] = useState(false); + const [position, setPosition] = useState(); + const map = useMap(); + + useEffect(() => { + if (!map) return; + + const centerChangedListener = (map: kakao.maps.Map) => { + setMapMoved(true); + + const bounds = map.getBounds(); + const swLatLng = bounds.getSouthWest(); + const neLatLng = bounds.getNorthEast(); + + setPosition({ + smallX: swLatLng.getLat(), + smallY: swLatLng.getLng(), + bigX: neLatLng.getLat(), + bigY: neLatLng.getLng() + }); + }; + + kakao.maps.event.addListener(map, "dragend", () => centerChangedListener(map)); + + return () => { + if (map) { + kakao.maps.event.removeListener(map, "dragend", () => centerChangedListener(map)); + } + }; + }, [map]); + + return { position, isMapMoved, setMapMoved }; +}; + +export default useSearch; diff --git a/src/hooks/useSeniorCenterList.tsx b/src/hooks/useSeniorCenterList.tsx new file mode 100644 index 0000000..97fe9be --- /dev/null +++ b/src/hooks/useSeniorCenterList.tsx @@ -0,0 +1,25 @@ +import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; +import postSeniorCenterList from "@apis/postSeniorCenterList"; + +const useSeniorCenterList = (searchValue?: string) => { + return useSuspenseInfiniteQuery({ + queryKey: ["seniorCenterList"], + queryFn: async ({ pageParam }) => { + return await postSeniorCenterList(pageParam, searchValue ?? ""); + }, + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + const isLastPage = allPages.length >= lastPage.total_count; + if (isLastPage) return undefined; + return allPages.length + 1; + }, + select: (data) => { + const results = data.pages.flatMap((page) => page.result); + const total_count = data.pages[0].total_count; + + return { results, total_count }; + } + }); +}; + +export default useSeniorCenterList; diff --git a/src/newPages/SeniorCenterList/SeniorCenterList.styles.ts b/src/newPages/SeniorCenterList/SeniorCenterList.styles.ts new file mode 100644 index 0000000..cb8bb44 --- /dev/null +++ b/src/newPages/SeniorCenterList/SeniorCenterList.styles.ts @@ -0,0 +1,9 @@ +import styled from "@emotion/styled"; + +export const ListContainer = styled.div` + overflow: scroll; +`; + +export const Spacer = styled.div` + height: 90px; +`; diff --git a/src/newPages/SeniorCenterList/SeniorCenterList.tsx b/src/newPages/SeniorCenterList/SeniorCenterList.tsx new file mode 100644 index 0000000..aeb4b38 --- /dev/null +++ b/src/newPages/SeniorCenterList/SeniorCenterList.tsx @@ -0,0 +1,57 @@ +import SearchBar from "@components/SearchBar/SearchBar"; +import { ProductType } from "@components/KakaoMap/KakaoMap.types"; +import ProductCard from "@components/ProductCard/ProductCard"; +import * as S from "./SeniorCenterList.styles"; +import Spinner from "@components/Spinner/Spinner"; +import useAroundSeniorCenter from "@hooks/useAroundSeniorCenter"; +import { useEffect, useState } from "react"; +import { isRefetched } from "@stores/searchStore"; +import { useRecoilState } from "recoil"; + +interface Position { + lat: number; + lng: number; +} + +const SeniorCenterList = () => { + const [mapCenter, setMapCenter] = useState(); + const [_isRefetched] = useRecoilState(isRefetched); + + const { data, error, isFetching, refetch } = useAroundSeniorCenter( + mapCenter?.lat as number, + mapCenter?.lng as number + ); + + if (error) console.log(error); + + useEffect(() => { + if (!_isRefetched && navigator.geolocation) { + navigator.geolocation.getCurrentPosition((position) => { + const newPosition = { + lat: position.coords.latitude, + lng: position.coords.longitude + }; + setMapCenter(newPosition); + }); + } + }, []); + + useEffect(() => { + if (mapCenter) refetch(); + }, [mapCenter]); + + return ( + + + {data?.result?.map((data: ProductType) => ( +
+ +
+ ))} + + {isFetching && } +
+ ); +}; + +export default SeniorCenterList; diff --git a/src/newPages/SeniorCenterMap/SeniorCenterMap.styles.ts b/src/newPages/SeniorCenterMap/SeniorCenterMap.styles.ts new file mode 100644 index 0000000..feb53cb --- /dev/null +++ b/src/newPages/SeniorCenterMap/SeniorCenterMap.styles.ts @@ -0,0 +1,7 @@ +import styled from "@emotion/styled"; + +export const MapContainer = styled.div` + width: 100%; + height: 100%; + margin-top: -96px; +`; diff --git a/src/newPages/SeniorCenterMap/SeniorCenterMap.tsx b/src/newPages/SeniorCenterMap/SeniorCenterMap.tsx new file mode 100644 index 0000000..648b52d --- /dev/null +++ b/src/newPages/SeniorCenterMap/SeniorCenterMap.tsx @@ -0,0 +1,58 @@ +import * as S from "./SeniorCenterMap.styles"; +import KakaoMap from "@components/KakaoMap/KakaoMap"; +import Spinner from "@components/Spinner/Spinner"; +import { useEffect, useState } from "react"; +import useAroundSeniorCenter from "@hooks/useAroundSeniorCenter"; +import { useLocation } from "react-router-dom"; + +interface Position { + lat: number; + lng: number; +} + +const SeniorCenterMap = () => { + const [mapCenter, setMapCenter] = useState(); + const query = new URLSearchParams(useLocation().search); + const latitude = query.get("latitude"); + const longitude = query.get("longitude"); + + const { data, error, isFetching, refetch } = useAroundSeniorCenter( + mapCenter?.lat as number, + mapCenter?.lng as number + ); + + if (error) console.log(error); + + useEffect(() => { + if (!latitude && !longitude && navigator.geolocation) { + navigator.geolocation.getCurrentPosition((position) => { + const newPosition = { + lat: position.coords.latitude, + lng: position.coords.longitude + }; + setMapCenter(newPosition); + }); + } + }, []); + + useEffect(() => { + if (latitude && longitude) setMapCenter({ lat: Number(latitude), lng: Number(longitude) }); + }, [latitude, longitude]); + + useEffect(() => { + if (mapCenter) refetch(); + }, [mapCenter]); + + return ( + + + {isFetching && } + + ); +}; + +export default SeniorCenterMap; diff --git a/src/newPages/SeniorCenterSearch/SeniorCenterSearch.styles.ts b/src/newPages/SeniorCenterSearch/SeniorCenterSearch.styles.ts new file mode 100644 index 0000000..bcf4260 --- /dev/null +++ b/src/newPages/SeniorCenterSearch/SeniorCenterSearch.styles.ts @@ -0,0 +1,77 @@ +import styled from "@emotion/styled"; + +export const SearchContainer = styled.div` + position: relative; + width: 100%; + height: 100%; +`; + +export const SearchBarWrapper = styled.div` + position: absolute; + top: 63px; + width: 100%; + + display: flex; + justify-content: center; + align-items: center; +`; + +export const SearchBar = styled.input` + width: 63%; + height: 44px; + flex-shrink: 0; + + border-radius: 12px; + border: 1px solid ${({ theme }) => theme.colors.gray[300]}; + background: #fff; + + margin-left: 8px; + + padding: 19px; +`; + +export const SearchBtn = styled.button` + color: #151515; + + ${({ theme }) => theme.text.body1_bold} + + margin-left: 10px; +`; + +export const ListWrapper = styled.div` + margin-top: 96px; + margin-bottom: 90px; + + width: 100%; + height: calc(100% - 96px); + + overflow: scroll; +`; + +export const ExpressionWrapper = styled.div` + position: absolute; + top: 175px; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + + gap: 44px; +`; + +export const AlertMsg = styled.div` + color: ${({ theme }) => theme.colors.gray[500]}; + text-align: center; + + ${({ theme }) => theme.text.body1_reg}; + + white-space: pre-wrap; +`; + +export const ProductsWrapper = styled.div` + position: absolute; + top: 95px; + width: 100%; +`; diff --git a/src/newPages/SeniorCenterSearch/SeniorCenterSearch.tsx b/src/newPages/SeniorCenterSearch/SeniorCenterSearch.tsx new file mode 100644 index 0000000..5252ced --- /dev/null +++ b/src/newPages/SeniorCenterSearch/SeniorCenterSearch.tsx @@ -0,0 +1,112 @@ +import * as S from "./SeniorCenterSearch.styles"; +import BackArrow from "@assets/icons/backArrow.svg?react"; +import SmileIcon from "@assets/icons/smileIcon.svg?react"; +import SadIcon from "@assets/icons/sadIcon.svg?react"; +import ProductCard from "@components/ProductCard/ProductCard"; +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import useSeniorCenterList from "@hooks/useSeniorCenterList"; +import useIntersect from "@hooks/useIntersect"; +import Spinner from "@components/Spinner/Spinner"; + +const SeniorCenterSearch = () => { + const defaultMsg = "지도를 옮겨서\n가까운 경로당을 찾아보세요"; + const noDataMsg = "검색 결과가 없어요.\n다시 한번 확인해주세요!"; + const navigate = useNavigate(); + const searchBarRef = useRef(null); + const searchBtnRef = useRef(null); + + const [isSearched, setIsSearched] = useState(false); + const [searchValue, setSearchValue] = useState(""); + + const { data, error, hasNextPage, isFetching, fetchNextPage, refetch } = + useSeniorCenterList(searchValue); + + if (error) console.log(error); + + const ref = useIntersect(async (entry, observer) => { + observer.unobserve(entry.target); + if (hasNextPage && !isFetching && data.results.length > 0) { + await fetchNextPage(); + } + }); + + useEffect(() => { + if (searchBarRef.current) searchBarRef.current.focus(); + }, []); + + useEffect(() => { + if (searchBarRef.current) { + searchBarRef.current.focus(); + + const handleKeyPress = (event: KeyboardEvent) => { + if (event.key === "Enter") { + searchBtnRef.current?.click(); + } + }; + searchBarRef.current.addEventListener("keypress", handleKeyPress); + + return () => { + searchBarRef.current?.removeEventListener("keypress", handleKeyPress); + }; + } + }, []); + + useEffect(() => { + console.log(data); + }, [data]); + + return ( + <> + +
navigate(-1)}> + +
+ + ) => + setSearchValue(event.target.value) + } + placeholder="검색어를 입력하세요" + ref={searchBarRef} + /> + { + setIsSearched(true); + await refetch(); + }} + ref={searchBtnRef} + > + 검색 + +
+ + {!isSearched ? ( + + + {defaultMsg} + + ) : isSearched && data.results.length === 0 ? ( + + + {noDataMsg} + + ) : ( + data.results.map((item) => ( + <> +
+ +
+
+ + )) + )} + {isFetching && } + + + ); +}; + +export default SeniorCenterSearch; diff --git a/src/newPages/tmpData.json b/src/newPages/tmpData.json new file mode 100644 index 0000000..b747b7b --- /dev/null +++ b/src/newPages/tmpData.json @@ -0,0 +1,144 @@ +{ + "products": [ + { + "facility_name": "성산LH경로당", + "id": 0, + "latitude": 33.45033032, + "longitude": 126.9108064, + "number_addr": "제주특별자치도 서귀포시 성산읍 고성리 1142", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 서귀포시 성산읍 고성동서로 33" + }, + { + "facility_name": "한마음 요양원", + "id": 1, + "latitude": 33.44543655, + "longitude": 126.9134733, + "number_addr": "제주특별자치도 서귀포시 성산읍 고성리 1003-3", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 서귀포시 성산읍 고성오조로 15" + }, + { + "facility_name": "성산읍노인복지회관", + "id": 2, + "latitude": 33.44839048, + "longitude": 126.9063725, + "number_addr": "제주특별자치도 서귀포시 성산읍 고성리 1549-1", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 서귀포시 성산읍 산성효자로 67" + }, + { + "facility_name": "삼달1리 경로당", + "id": 3, + "latitude": 33.37409847, + "longitude": 126.8472648, + "number_addr": "제주특별자치도 서귀포시 성산읍 삼달리 724-1", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 서귀포시 성산읍 삼달로 209" + }, + { + "facility_name": "수산1리경로당", + "id": 4, + "latitude": 33.446746, + "longitude": 126.8825851, + "number_addr": "제주특별자치도 서귀포시 성산읍 수산리 1199-1", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 서귀포시 성산읍 수산서남로 26" + }, + { + "facility_name": "수산2리경로당", + "id": 5, + "latitude": 33.44215479, + "longitude": 126.8634756, + "number_addr": "제주특별자치도 서귀포시 성산읍 수산리 2432-1", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 서귀포시 성산읍 수산서남로 79번길 154" + }, + { + "facility_name": "신풍리 사무소", + "id": 6, + "latitude": 33.36139893, + "longitude": 126.8352691, + "number_addr": "제주특별자치도 서귀포시 성산읍 신풍리 726-1", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 서귀포시 성산읍 신풍상동로 4" + }, + { + "facility_name": "오조리사무소", + "id": 7, + "latitude": 33.46148582, + "longitude": 126.914987, + "number_addr": "제주특별자치도 서귀포시 성산읍 오조리 155-1", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 서귀포시 성산읍 오조로 85" + }, + { + "facility_name": "삼달2리 경로당", + "id": 8, + "latitude": 33.36940843, + "longitude": 126.8687192, + "number_addr": "제주특별자치도 서귀포시 성산읍 삼달리 29-2", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 서귀포시 성산읍 일주동로 5252" + }, + { + "facility_name": "하도리 경로당", + "id": 9, + "latitude": 33.51766239, + "longitude": 126.8836615, + "number_addr": "제주특별자치도 제주시 구좌읍 하도리 1318-1", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 제주시 구좌읍 일주동로 3425" + }, + { + "facility_name": "상한동 복지회관", + "id": 10, + "latitude": 33.53480165, + "longitude": 126.8233205, + "number_addr": "제주특별자치도 제주시 구좌읍 한동리 947", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 제주시 구좌읍 한동로 64" + }, + { + "facility_name": "세화요양원", + "id": 11, + "latitude": 33.52197875, + "longitude": 126.8528362, + "number_addr": "제주특별자치도 제주시 구좌읍 세화리 1558-1", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 제주시 구좌읍 세화서길 7-1" + }, + { + "facility_name": "평대리 경로당", + "id": 12, + "latitude": 33.52838728, + "longitude": 126.8427599684, + "number_addr": "제주특별자치도 제주시 구좌읍 평대리 733", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 제주시 구좌읍 일주동로 3007" + }, + { + "facility_name": "평대리동동 경로당", + "id": 13, + "latitude": 33.5243484, + "longitude": 126.8487004, + "number_addr": "제주특별자치도 제주시 구좌읍 평대리 362", + "operation_time": "월~금 09:00~18:00", + "phone_number": "064-710-6413", + "road_name_addr": "제주특별자치도 제주시 구좌읍 일주동로 3080" + } + ] +} diff --git a/src/routes/router.tsx b/src/routes/router.tsx index 9c849eb..ae76430 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -10,7 +10,9 @@ import OtherCollection from "../pages/otherCollection/OtherCollection"; import WriteDiary from "../pages/writeDiary/WriteDiary"; import NewBottom from "@components/NewBottomNav/NewBottomNav"; import Header from "@components/HeaderNav/HeaderNav"; -import MyInfo from "../newPages/myInfo/MyInfo"; +import SeniorCenterMap from "@newPages/SeniorCenterMap/SeniorCenterMap"; +import SeniorCenterList from "@newPages/SeniorCenterList/SeniorCenterList"; +import SeniorCenterSearch from "@newPages/SeniorCenterSearch/SeniorCenterSearch"; const router = createBrowserRouter([ { @@ -98,6 +100,32 @@ const router = createBrowserRouter([ ) }, { + path: "/seniorCenterMap", + element: ( + <> + + + + ) + }, + { + path: "/seniorCenterList", + element: ( + <> + + + + ) + }, + { + path: "/seniorCenterSearch", + element: ( + <> + + + + ) + }, path: "/lesson", element: ( <> diff --git a/src/stores/searchStore.ts b/src/stores/searchStore.ts new file mode 100644 index 0000000..3cdaa36 --- /dev/null +++ b/src/stores/searchStore.ts @@ -0,0 +1,6 @@ +import { atom } from "recoil"; + +export const isRefetched = atom({ + key: "diaryState", + default: false +}); diff --git a/tsconfig.json b/tsconfig.json index b4c36e2..5cdc3d7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,5 +35,6 @@ "noFallthroughCasesInSwitch": true }, "include": ["src", "src/custom.d.ts"], - "references": [{ "path": "./tsconfig.node.json" }] + "references": [{ "path": "./tsconfig.node.json" }], + "types": ["kakao.maps.d.ts"] }