diff --git a/apps/customer/src/app/(tabs)/main/_apis/searchAddressByKakao.ts b/apps/customer/src/app/(tabs)/main/_apis/searchAddressByKakao.ts new file mode 100644 index 0000000..79ace98 --- /dev/null +++ b/apps/customer/src/app/(tabs)/main/_apis/searchAddressByKakao.ts @@ -0,0 +1,72 @@ +"use client"; + +import type { AddressSearchItem } from "../_types/address-search"; + +export function searchAddressByKakao( + keyword: string, + size = 10 +): Promise { + return new Promise((resolve, reject) => { + const trimmedKeyword = keyword.trim(); + + if (!trimmedKeyword) { + resolve([]); + return; + } + + if ( + typeof window === "undefined" || + !window.kakao || + !window.kakao.maps || + !window.kakao.maps.services + ) { + reject(new Error("Kakao services library is not loaded.")); + return; + } + + const geocoder = new window.kakao.maps.services.Geocoder(); + + geocoder.addressSearch( + trimmedKeyword, + (result: KakaoAddressSearchResult[], status: string) => { + const { kakao } = window; + + if (status === kakao.maps.services.Status.ZERO_RESULT) { + resolve([]); + return; + } + + if (status !== kakao.maps.services.Status.OK) { + reject(new Error("주소 검색 중 오류가 발생했습니다.")); + return; + } + + const mapped = result.slice(0, size).map( + (item: KakaoAddressSearchResult, index: number) => { + const lotNumberAddress = + item.address?.address_name || item.address_name || ""; + + const roadAddress = + item.road_address?.address_name || item.address_name || ""; + + return { + id: `${item.x}-${item.y}-${index}`, + label: roadAddress || lotNumberAddress, + lotNumberAddress, + roadAddress, + longitude: Number(item.x), + latitude: Number(item.y), + }; + } + ); + + resolve(mapped); + }, + { + page: 1, + size, + analyze_type: window.kakao.maps.services.AnalyzeType.SIMILAR, + } + ); + }); +} \ No newline at end of file diff --git a/apps/customer/src/app/(tabs)/main/_components/AddressSearchBottomSheet.tsx b/apps/customer/src/app/(tabs)/main/_components/AddressSearchBottomSheet.tsx new file mode 100644 index 0000000..971d11e --- /dev/null +++ b/apps/customer/src/app/(tabs)/main/_components/AddressSearchBottomSheet.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { BottomSheet, Input, Icon } from "@compasser/design-system"; +import type { AddressSearchItem } from "../_types/address-search"; +import { searchAddressByKakao } from "../_apis/searchAddressByKakao"; + +interface AddressSearchBottomSheetProps { + open: boolean; + onClose: () => void; + onSelectAddress: (item: AddressSearchItem) => void; +} + +export default function AddressSearchBottomSheet({ + open, + onClose, + onSelectAddress, +}: AddressSearchBottomSheetProps) { + const [keyword, setKeyword] = useState(""); + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + const debounceRef = useRef(null); + + useEffect(() => { + if (!open) { + setKeyword(""); + setResults([]); + setIsSearching(false); + } + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + + const trimmedKeyword = keyword.trim(); + + if (!trimmedKeyword) { + setResults([]); + setIsSearching(false); + return; + } + + if (debounceRef.current) { + window.clearTimeout(debounceRef.current); + } + + debounceRef.current = window.setTimeout(async () => { + try { + setIsSearching(true); + const searched = await searchAddressByKakao(trimmedKeyword, 10); + setResults(searched); + } catch (error) { + console.error(error); + setResults([]); + } finally { + setIsSearching(false); + } + }, 250); + + return () => { + if (debounceRef.current) { + window.clearTimeout(debounceRef.current); + } + }; + }, [keyword, open]); + + const handleQuickSelect = () => { + onSelectAddress({ + id: "catholic-univ", + label: "가톨릭대 주변 탐색", + lotNumberAddress: "경기도 부천시 원미구 역곡동 일대", + roadAddress: "가톨릭대학교 성심교정 주변", + longitude: 126.8016, + latitude: 37.4875, + }); + }; + + const handleSelectItem = (item: AddressSearchItem) => { + onSelectAddress(item); + }; + + return ( + + setKeyword(event.target.value)} + placeholder="위치 입력" + /> + + + + {results.length > 0 && ( +
+ {results.map((item) => ( + + ))} +
+ )} + + {keyword.trim() && isSearching && ( +

+ 검색 중... +

+ )} + + {keyword.trim() && !isSearching && results.length === 0 && ( +

+ 검색 결과가 없습니다. +

+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/customer/src/app/(tabs)/main/_components/KakaoMap.tsx b/apps/customer/src/app/(tabs)/main/_components/KakaoMap.tsx index 215873b..6ccd953 100644 --- a/apps/customer/src/app/(tabs)/main/_components/KakaoMap.tsx +++ b/apps/customer/src/app/(tabs)/main/_components/KakaoMap.tsx @@ -2,14 +2,19 @@ import Script from "next/script"; import { useEffect, useRef, useState } from "react"; -import { MOCK_MAIN_STORE_LIST } from "../_constants/MockMainList"; -import type { MainCategory, MainStoreItem } from "../_types/main-list"; +import type { + GetStoreReqDTO, + SimpleStoreInfoDTO, + StoreTag, +} from "@compasser/api"; +import type { MainCategory } from "../_types/main-list"; +import type { AddressSearchItem } from "../_types/address-search"; +import { useStoreSimple } from "@/shared/queries/query/store/useStoreSimple"; import MapStoreBottomSheet from "./MapStoreBottomSheet"; -declare global { - interface Window { - kakao: any; - } +interface KakaoMapProps { + selectedAddress: AddressSearchItem | null; + stores: GetStoreReqDTO[]; } const PIN_IMAGE_MAP: Record = { @@ -18,12 +23,16 @@ const PIN_IMAGE_MAP: Record = { 식당: "/icons/pin-restaurant.svg", }; -export default function KakaoMap() { +export default function KakaoMap({ selectedAddress, stores }: KakaoMapProps) { const mapRef = useRef(null); - const mapInstanceRef = useRef(null); - const markerRefs = useRef([]); + const mapInstanceRef = useRef(null); + const markerRefs = useRef([]); + const selectedMarkerRef = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); - const [selectedStore, setSelectedStore] = useState(null); + const [selectedStoreId, setSelectedStoreId] = useState(null); + + const { data: simpleStore } = useStoreSimple(selectedStoreId); useEffect(() => { if (window.kakao?.maps) { @@ -46,7 +55,7 @@ export default function KakaoMap() { }); kakaoMaps.event.addListener(mapInstanceRef.current, "click", () => { - setSelectedStore(null); + setSelectedStoreId(null); }); } else { mapInstanceRef.current.relayout(); @@ -55,13 +64,13 @@ export default function KakaoMap() { markerRefs.current.forEach((marker) => marker.setMap(null)); markerRefs.current = []; - MOCK_MAIN_STORE_LIST.forEach((store) => { + stores.forEach((store) => { const markerImage = new kakaoMaps.MarkerImage( - PIN_IMAGE_MAP[store.primaryCategory], + PIN_IMAGE_MAP[mapServerTagToMainCategory(store.tag)], new kakaoMaps.Size(32, 32), { offset: new kakaoMaps.Point(16, 16), - }, + } ); const marker = new kakaoMaps.Marker({ @@ -73,36 +82,103 @@ export default function KakaoMap() { kakaoMaps.event.addListener(marker, "click", () => { setTimeout(() => { - setSelectedStore(store); + setSelectedStoreId(store.storeId); }, 0); }); markerRefs.current.push(marker); }); + + if (selectedAddress) { + const selectedPosition = new kakaoMaps.LatLng( + selectedAddress.latitude, + selectedAddress.longitude + ); + + mapInstanceRef.current.setCenter(selectedPosition); + + if (selectedMarkerRef.current) { + selectedMarkerRef.current.setMap(null); + } + + selectedMarkerRef.current = new kakaoMaps.Marker({ + map: mapInstanceRef.current, + position: selectedPosition, + }); + } else if (stores.length > 0) { + const firstStore = stores[0]; + mapInstanceRef.current.setCenter( + new kakaoMaps.LatLng(firstStore.latitude, firstStore.longitude) + ); + } }); - }, [isLoaded]); + }, [isLoaded, stores, selectedAddress]); return ( <>