diff --git a/src/components/common/SearchField.tsx b/src/components/common/SearchField.tsx index dda1d6b..081e6aa 100644 --- a/src/components/common/SearchField.tsx +++ b/src/components/common/SearchField.tsx @@ -7,6 +7,8 @@ export const SEARCH_FIELD_DEFAULT_PLACEHOLDER = "검색"; export type SearchFieldProps = Omit, "type"> & { inputClassName?: string; + searchButtonLabel?: string; + onSubmitSearch?: () => void; }; export const SearchField = React.forwardRef( @@ -14,9 +16,12 @@ export const SearchField = React.forwardRef( { className, inputClassName, + searchButtonLabel = "검색", + onSubmitSearch, id, placeholder = SEARCH_FIELD_DEFAULT_PLACEHOLDER, "aria-label": ariaLabel, + onKeyDown, ...inputProps }, ref, @@ -36,12 +41,22 @@ export const SearchField = React.forwardRef( "border-input placeholder:text-muted-foreground bg-background text-foreground h-10 w-full rounded-full border px-3.5 pe-10 text-sm outline-none focus-visible:ring-0", inputClassName, )} + onKeyDown={(event) => { + onKeyDown?.(event); + if (!event.defaultPrevented && event.key === "Enter") { + onSubmitSearch?.(); + } + }} {...inputProps} /> - + ); }, diff --git a/src/components/course-planner/RegionSelectionPanel.tsx b/src/components/course-planner/RegionSelectionPanel.tsx index 0ee10b3..92b8f13 100644 --- a/src/components/course-planner/RegionSelectionPanel.tsx +++ b/src/components/course-planner/RegionSelectionPanel.tsx @@ -1,7 +1,10 @@ import { X } from "lucide-react"; import { SearchField } from "@/components/common/SearchField"; -import { COURSE_DISTRICTS_BY_CITY, COURSE_REGION_CITIES } from "@/features/course-planner/constants"; +import { + COURSE_DISTRICTS_BY_CITY, + COURSE_REGION_CITIES, +} from "@/features/course-planner/constants"; import { cn } from "@/lib/utils"; type RegionSelectionPanelProps = { diff --git a/src/components/map/KakaoMapView.tsx b/src/components/map/KakaoMapView.tsx index 376a187..e19b99f 100644 --- a/src/components/map/KakaoMapView.tsx +++ b/src/components/map/KakaoMapView.tsx @@ -15,8 +15,12 @@ export type KakaoMapViewProps = { appKey?: string; places: SavedPlace[]; center: MapCoordinate; + fitBoundsPlaces?: SavedPlace[]; + geocodeKeyword?: string; + viewportKey?: string; level?: number; className?: string; + onMapClick?: () => void; }; type MapLoadState = "loading" | "ready" | "error"; @@ -25,7 +29,17 @@ type MapLoadState = "loading" | "ready" | "error"; * Kakao JS SDK 로딩과 지도/마커 렌더링을 담당. * 현재는 단일 마커 스타일만 사용하고, 상세 오버레이는 연결하지 않는다. */ -export function KakaoMapView({ appKey, places, center, level = 4, className }: KakaoMapViewProps) { +export function KakaoMapView({ + appKey, + places, + center, + fitBoundsPlaces = [], + geocodeKeyword = "", + viewportKey = "initial", + level = 4, + className, + onMapClick, +}: KakaoMapViewProps) { const mapKey = appKey?.trim() ?? ""; const hasMapKey = mapKey.length > 0; const initialCenterRef = useRef(center); @@ -100,14 +114,125 @@ export function KakaoMapView({ appKey, places, center, level = 4, className }: K }; }, [hasMapKey, mapKey]); - // effect B: center 변경 시 setCenter만 수행 + // effect B: requested viewport changes. useEffect(() => { if (loadState !== "ready" || !mapRef.current || !mapsRef.current) return; const maps = mapsRef.current; const mapInstance = mapRef.current; - const nextCenter = new maps.LatLng(center.latitude, center.longitude); - mapInstance.setCenter(nextCenter); - }, [center.latitude, center.longitude, loadState]); + + if (fitBoundsPlaces.length > 1) { + const bounds = new maps.LatLngBounds(); + fitBoundsPlaces.forEach((place) => { + bounds.extend(new maps.LatLng(place.latitude, place.longitude)); + }); + mapInstance.setBounds(bounds); + return; + } + + if (fitBoundsPlaces.length === 1) { + const [place] = fitBoundsPlaces; + mapInstance.setLevel(level); + mapInstance.setCenter(new maps.LatLng(place.latitude, place.longitude)); + return; + } + + const trimmedGeocodeKeyword = geocodeKeyword.trim(); + if (trimmedGeocodeKeyword && maps.services) { + mapInstance.setLevel(level); + let disposed = false; + const normalizeResultText = (value: string | undefined) => + (value ?? "").trim().toLowerCase().replace(/\s+/g, ""); + const normalizedKeyword = normalizeResultText(trimmedGeocodeKeyword); + const scoreResult = (result: { + place_name?: string; + address_name?: string; + road_address_name?: string; + }) => { + const texts = [ + normalizeResultText(result.place_name), + normalizeResultText(result.address_name), + normalizeResultText(result.road_address_name), + ]; + + if (texts.some((text) => text === normalizedKeyword)) return 0; + if (texts.some((text) => text.includes(normalizedKeyword))) return 1; + if (texts.some((text) => normalizedKeyword.includes(text) && text.length > 0)) return 2; + return 3; + }; + const moveToBestResult = ( + results: Array<{ + x: string; + y: string; + place_name?: string; + address_name?: string; + road_address_name?: string; + }>, + ) => { + const [result] = [...results].sort((a, b) => scoreResult(a) - scoreResult(b)); + const latitude = Number(result?.y); + const longitude = Number(result?.x); + if (disposed || Number.isNaN(latitude) || Number.isNaN(longitude)) { + return false; + } + + mapInstance.setCenter(new maps.LatLng(latitude, longitude)); + return true; + }; + const moveToFallbackCenter = () => { + if (!disposed) { + mapInstance.setCenter(new maps.LatLng(center.latitude, center.longitude)); + } + }; + + const services = maps.services; + const geocoder = new services.Geocoder(); + geocoder.addressSearch(trimmedGeocodeKeyword, (addressResults, addressStatus) => { + if (disposed) { + return; + } + + if (addressStatus === services.Status.OK && moveToBestResult(addressResults)) { + return; + } + + const places = new services.Places(); + places.keywordSearch(trimmedGeocodeKeyword, (keywordResults, keywordStatus) => { + if (keywordStatus === services.Status.OK && moveToBestResult(keywordResults)) { + return; + } + + moveToFallbackCenter(); + }); + }); + + return () => { + disposed = true; + }; + } + + mapInstance.setLevel(level); + mapInstance.setCenter(new maps.LatLng(center.latitude, center.longitude)); + }, [ + center.latitude, + center.longitude, + fitBoundsPlaces, + geocodeKeyword, + level, + loadState, + viewportKey, + ]); + + useEffect(() => { + if (loadState !== "ready" || !mapRef.current || !mapsRef.current || !onMapClick) return; + + const maps = mapsRef.current; + const mapInstance = mapRef.current; + maps.event.addListener(mapInstance, "click", onMapClick); + + return () => { + maps.event.removeListener(mapInstance, "click", onMapClick); + }; + }, [loadState, onMapClick]); // effect C: places 변경 시 marker만 갱신 useEffect(() => { diff --git a/src/components/map/MapSearchOverlay.tsx b/src/components/map/MapSearchOverlay.tsx index 5c1173c..94112e5 100644 --- a/src/components/map/MapSearchOverlay.tsx +++ b/src/components/map/MapSearchOverlay.tsx @@ -1,22 +1,32 @@ import { memo, useRef } from "react"; +import type { MapSearchSuggestion } from "@/features/map/utils/map-search"; import { usePointerDownOutside } from "@/hooks/use-pointer-down-outside"; import { SearchField } from "../common/SearchField"; import { MAP_SEARCH_INPUT_GLASS_CLASS } from "./chip-style"; import { FilterBar } from "./FilterBar"; import type { MapFilterBarProps } from "./filters/map-filter-bar-props"; +import { MapSearchSuggestions } from "./MapSearchSuggestions"; export type MapSearchOverlayProps = MapFilterBarProps & { placeholder: string; keyword: string; + searchSuggestions: MapSearchSuggestion[]; + isSearchSuggestionsOpen: boolean; onKeywordChange: (keyword: string) => void; + onSubmitSearch: () => void; + onSelectSearchPlace: (placeId: string) => void; }; export const MapSearchOverlay = memo(function MapSearchOverlay({ placeholder, keyword, + searchSuggestions, + isSearchSuggestionsOpen, onKeywordChange, + onSubmitSearch, + onSelectSearchPlace, onCloseTagPanel, isTagPanelOpen, ...filterBarProps @@ -33,11 +43,17 @@ export const MapSearchOverlay = memo(function MapSearchOverlay({ value={keyword} placeholder={placeholder} onChange={(event) => onKeywordChange(event.target.value)} + onSubmitSearch={onSubmitSearch} inputClassName={MAP_SEARCH_INPUT_GLASS_CLASS} /> + -
+
void; +}; + +export function MapSearchSuggestions({ + suggestions, + open, + className, + onSelectPlace, +}: MapSearchSuggestionsProps) { + if (!open) { + return null; + } + + return ( +
+ {suggestions.length > 0 ? ( +
    + {suggestions.map(({ place }) => ( +
  • + +
  • + ))} +
+ ) : ( +
관련 장소가 없음
+ )} +
+ ); +} diff --git a/src/components/map/chip-style.ts b/src/components/map/chip-style.ts index a359839..f9f15c3 100644 --- a/src/components/map/chip-style.ts +++ b/src/components/map/chip-style.ts @@ -26,9 +26,14 @@ export const MAP_CHIP_BADGE_SELECTED_MOBILE_CLASS = export const MAP_CHIP_BADGE_UNSELECTED_MOBILE_CLASS = "max-md:absolute max-md:-top-1 max-md:-right-0.5 max-md:z-1 max-md:flex max-md:h-4 max-md:min-w-4 max-md:items-center max-md:justify-center max-md:rounded-full max-md:px-1 max-md:tabular-nums max-md:font-semibold max-md:shadow-sm max-md:border max-md:border-primary/30 max-md:bg-primary max-md:text-primary-foreground"; +/** 검색창·검색 제안 패널 공통 글래스 (입력창은 아래 클래스에서 border-width는 기본 input과 함께 사용) */ +export const MAP_SEARCH_GLASS_SURFACE_CLASS = "border-border/55 bg-background/85 backdrop-blur-md"; + /** 검색창 한 줄만 — 넓은 글래스 패널 없이 얇게 */ -export const MAP_SEARCH_INPUT_GLASS_CLASS = - "border-border/55 bg-background/85 backdrop-blur-md placeholder:text-muted-foreground/90"; +export const MAP_SEARCH_INPUT_GLASS_CLASS = `${MAP_SEARCH_GLASS_SURFACE_CLASS} placeholder:text-muted-foreground/90`; + +/** 검색 제안 드롭다운 패널 (div 래퍼용 — `border` 명시) */ +export const MAP_SEARCH_SUGGESTIONS_PANEL_CLASS = `border ${MAP_SEARCH_GLASS_SURFACE_CLASS}`; export const MAP_FILTER_PANEL_BASE_CLASS = "mt-2 overflow-hidden rounded-2xl border border-border/35 bg-background/88 shadow-filter-panel backdrop-blur-md transition-all duration-200 ease-out"; diff --git a/src/components/mypage/MySavedPlacesPage.tsx b/src/components/mypage/MySavedPlacesPage.tsx index 38b8f44..f7069f0 100644 --- a/src/components/mypage/MySavedPlacesPage.tsx +++ b/src/components/mypage/MySavedPlacesPage.tsx @@ -14,9 +14,7 @@ import { usePointerDownOutside } from "@/hooks/use-pointer-down-outside"; import { cn } from "@/lib/utils"; import { resolveSavedPlacesBusinessHours, useKoreanNow } from "@/shared/lib/place-business-hours"; import { SAVED_PLACE_BY_ID } from "@/shared/mocks/place-mocks"; -import { - type SavedPlace as MapSavedPlace, -} from "@/shared/types/map-home"; +import { type SavedPlace as MapSavedPlace } from "@/shared/types/map-home"; import type { SavedPlace } from "@/shared/types/my-page"; import { usePlaceDetailStore } from "@/store/place-detail-store"; diff --git a/src/features/map/hooks/use-map-search-filters.ts b/src/features/map/hooks/use-map-search-filters.ts index 744a68c..8bf4bb0 100644 --- a/src/features/map/hooks/use-map-search-filters.ts +++ b/src/features/map/hooks/use-map-search-filters.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from "react"; import type { Category } from "@/features/map/api/place-taxonomy-types"; +import { includesMapSearchText, normalizeMapSearchText } from "@/features/map/utils/map-search"; import { MAP_ALL_CATEGORY_FILTER_CHIP, type MapCategoryFilterChip, @@ -282,7 +283,7 @@ export function useMapSearchFilters({ const selectedCategorySet = useMemo(() => new Set(activeCategories), [activeCategories]); const filteredPlaces = useMemo(() => { - const normalizedKeyword = keyword.trim().toLowerCase(); + const normalizedKeyword = normalizeMapSearchText(keyword); const hasSelectedCategoryFilter = activeCategories.length > 0; return places.filter((place) => { @@ -322,8 +323,7 @@ export function useMapSearchFilters({ return true; } - const searchableText = `${place.name} ${place.address}`.toLowerCase(); - return searchableText.includes(normalizedKeyword); + return includesMapSearchText(`${place.name} ${place.address}`, keyword); }); }, [ activeCategories.length, diff --git a/src/features/map/utils/map-search.ts b/src/features/map/utils/map-search.ts new file mode 100644 index 0000000..2e9da58 --- /dev/null +++ b/src/features/map/utils/map-search.ts @@ -0,0 +1,213 @@ +import type { MapCoordinate, SavedPlace } from "@/shared/types/map-home"; + +export type MapSearchSuggestion = { + place: SavedPlace; + matchType: "name" | "address"; +}; + +const DEFAULT_SUGGESTION_LIMIT = 5; + +const KNOWN_LOCATION_CENTERS: Array<{ keywords: string[]; center: MapCoordinate }> = [ + { keywords: ["서울", "서울시", "서울특별시"], center: { latitude: 37.5665, longitude: 126.978 } }, + { keywords: ["제주", "제주시"], center: { latitude: 33.4996, longitude: 126.5312 } }, + { + keywords: ["대구", "대구시", "대구광역시"], + center: { latitude: 35.8714, longitude: 128.6014 }, + }, + { keywords: ["광명", "광명시"], center: { latitude: 37.4785, longitude: 126.8646 } }, + { + keywords: ["인천", "인천시", "인천광역시"], + center: { latitude: 37.4563, longitude: 126.7052 }, + }, + { + keywords: ["부산", "부산시", "부산광역시"], + center: { latitude: 35.1796, longitude: 129.0756 }, + }, + { + keywords: ["대전", "대전시", "대전광역시"], + center: { latitude: 36.3504, longitude: 127.3845 }, + }, + { + keywords: ["광주", "광주시", "광주광역시"], + center: { latitude: 35.1595, longitude: 126.8526 }, + }, + { + keywords: ["울산", "울산시", "울산광역시"], + center: { latitude: 35.5384, longitude: 129.3114 }, + }, + { keywords: ["세종", "세종시"], center: { latitude: 36.4801, longitude: 127.289 } }, + { keywords: ["이문", "이문동"], center: { latitude: 37.5943, longitude: 127.0615 } }, + { keywords: ["회기", "회기동"], center: { latitude: 37.5919, longitude: 127.0555 } }, + { keywords: ["동대", "동대문", "동대문구"], center: { latitude: 37.5744, longitude: 127.0396 } }, + { keywords: ["소하", "소하동"], center: { latitude: 37.4484, longitude: 126.8835 } }, + { keywords: ["철산", "철산동"], center: { latitude: 37.476, longitude: 126.8679 } }, + { keywords: ["하안", "하안동"], center: { latitude: 37.461, longitude: 126.8784 } }, + { keywords: ["사당", "사당동"], center: { latitude: 37.4766, longitude: 126.9816 } }, + { keywords: ["연남", "연남동"], center: { latitude: 37.5627, longitude: 126.9217 } }, + { keywords: ["성수", "성수동"], center: { latitude: 37.5446, longitude: 127.0557 } }, + { keywords: ["한남", "한남동"], center: { latitude: 37.5345, longitude: 127.0062 } }, + { keywords: ["애월", "애월읍"], center: { latitude: 33.462, longitude: 126.31 } }, + { keywords: ["일직", "일직동"], center: { latitude: 37.421, longitude: 126.8846 } }, + { keywords: ["수성", "수성구", "수성못"], center: { latitude: 35.8273, longitude: 128.6163 } }, + { keywords: ["동성로"], center: { latitude: 35.8704, longitude: 128.5942 } }, + { keywords: ["송도", "송도동"], center: { latitude: 37.3925, longitude: 126.6415 } }, +]; + +export function normalizeMapSearchText(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/\s+/g, "") + .replace(/특별자치시$/, "") + .replace(/특별시$/, "") + .replace(/광역시$/, "") + .replace(/자치구$/, ""); + + if (normalized.length >= 3) { + return normalized.replace(/[시군구동읍면]$/, ""); + } + + return normalized; +} + +function getKnownLocationCenter(keyword: string): MapCoordinate | null { + const normalizedKeyword = normalizeMapSearchText(keyword); + if (!normalizedKeyword) { + return null; + } + + const knownLocation = KNOWN_LOCATION_CENTERS.find(({ keywords }) => + keywords.some( + (locationKeyword) => normalizeMapSearchText(locationKeyword) === normalizedKeyword, + ), + ); + + return knownLocation?.center ?? null; +} + +export function isMapLocationSearch(keyword: string): boolean { + const trimmedKeyword = keyword.trim(); + if (!trimmedKeyword) { + return false; + } + + return Boolean(getKnownLocationCenter(trimmedKeyword)) || /[시군구동읍면]$/.test(trimmedKeyword); +} + +export function includesMapSearchText(value: string, keyword: string): boolean { + const normalizedKeyword = normalizeMapSearchText(keyword); + if (!normalizedKeyword) { + return true; + } + + return normalizeMapSearchText(value).includes(normalizedKeyword); +} + +function comparePlacesByKoreanName(a: SavedPlace, b: SavedPlace): number { + const nameCompare = a.name.localeCompare(b.name, "ko-KR", { numeric: true }); + if (nameCompare !== 0) { + return nameCompare; + } + + return a.address.localeCompare(b.address, "ko-KR", { numeric: true }); +} + +function comparePlacesByNameMatch(keyword: string) { + return (a: SavedPlace, b: SavedPlace): number => { + const normalizedKeyword = normalizeMapSearchText(keyword); + const aMatchIndex = normalizeMapSearchText(a.name).indexOf(normalizedKeyword); + const bMatchIndex = normalizeMapSearchText(b.name).indexOf(normalizedKeyword); + + if (aMatchIndex !== bMatchIndex) { + return aMatchIndex - bMatchIndex; + } + + return comparePlacesByKoreanName(a, b); + }; +} + +export function buildMapSearchSuggestions( + places: SavedPlace[], + keyword: string, + limit = DEFAULT_SUGGESTION_LIMIT, +): MapSearchSuggestion[] { + const normalizedKeyword = normalizeMapSearchText(keyword); + if (!normalizedKeyword) { + return []; + } + + const nameMatches = places + .filter((place) => includesMapSearchText(place.name, keyword)) + .sort(comparePlacesByNameMatch(keyword)) + .map((place) => ({ place, matchType: "name" as const })); + + const nameMatchIds = new Set(nameMatches.map(({ place }) => place.id)); + const addressMatches = places + .filter((place) => !nameMatchIds.has(place.id) && includesMapSearchText(place.address, keyword)) + .sort(comparePlacesByKoreanName) + .map((place) => ({ place, matchType: "address" as const })); + + return [...nameMatches, ...addressMatches].slice(0, limit); +} + +export function findMapSearchMatchedPlaces(places: SavedPlace[], keyword: string): SavedPlace[] { + const normalizedKeyword = normalizeMapSearchText(keyword); + if (!normalizedKeyword) { + return []; + } + + const nameMatches = places + .filter((place) => includesMapSearchText(place.name, keyword)) + .sort(comparePlacesByNameMatch(keyword)); + const nameMatchIds = new Set(nameMatches.map((place) => place.id)); + const addressMatches = places + .filter((place) => !nameMatchIds.has(place.id) && includesMapSearchText(place.address, keyword)) + .sort(comparePlacesByKoreanName); + + return [...nameMatches, ...addressMatches]; +} + +export function findMapLocationMatchedPlaces(places: SavedPlace[], keyword: string): SavedPlace[] { + const normalizedKeyword = normalizeMapSearchText(keyword); + if (!normalizedKeyword) { + return []; + } + + return places + .filter((place) => includesMapSearchText(place.address, keyword)) + .sort(comparePlacesByKoreanName); +} + +export function findMapSearchCenter( + places: SavedPlace[], + keyword: string, + fallbackCenter: MapCoordinate, +): MapCoordinate { + const normalizedKeyword = normalizeMapSearchText(keyword); + if (!normalizedKeyword) { + return fallbackCenter; + } + + const knownLocationCenter = getKnownLocationCenter(keyword); + if (knownLocationCenter) { + return knownLocationCenter; + } + + const addressMatches = places.filter((place) => includesMapSearchText(place.address, keyword)); + if (addressMatches.length === 0) { + return fallbackCenter; + } + + const coordinateSum = addressMatches.reduce( + (accumulator, place) => ({ + latitude: accumulator.latitude + place.latitude, + longitude: accumulator.longitude + place.longitude, + }), + { latitude: 0, longitude: 0 }, + ); + + return { + latitude: coordinateSum.latitude / addressMatches.length, + longitude: coordinateSum.longitude / addressMatches.length, + }; +} diff --git a/src/features/room/api/room-service.ts b/src/features/room/api/room-service.ts index 1ea8e8f..90623bc 100644 --- a/src/features/room/api/room-service.ts +++ b/src/features/room/api/room-service.ts @@ -52,14 +52,10 @@ export const roomService = { payload: UpdateRoomPinRequest, ): Promise => { return withCsrfRetry(async () => { - const res = await api.patch>( - API_PATHS.rooms.pin(roomId), - payload, - { - withCredentials: true, - headers: getXsrfHeader(), - }, - ); + const res = await api.patch>(API_PATHS.rooms.pin(roomId), payload, { + withCredentials: true, + headers: getXsrfHeader(), + }); return toRoomActionResult(res.data.message); }); }, @@ -76,10 +72,14 @@ export const roomService = { createRoom: async (payload: CreateRoomRequest): Promise => { return withCsrfRetry(async () => { - const res = await api.post>(API_PATHS.rooms.root, payload, { - withCredentials: true, - headers: getXsrfHeader(), - }); + const res = await api.post>( + API_PATHS.rooms.root, + payload, + { + withCredentials: true, + headers: getXsrfHeader(), + }, + ); return res.data.data; }); }, @@ -96,10 +96,14 @@ export const roomService = { registerLink: async (payload: RegisterLinkRequest): Promise => { return withCsrfRetry(async () => { - const res = await api.post>(API_PATHS.links.root, payload, { - withCredentials: true, - headers: getXsrfHeader(), - }); + const res = await api.post>( + API_PATHS.links.root, + payload, + { + withCredentials: true, + headers: getXsrfHeader(), + }, + ); return res.data.data; }); }, diff --git a/src/pages/map/MapHomePage.tsx b/src/pages/map/MapHomePage.tsx index c67dd9c..ce23d5f 100644 --- a/src/pages/map/MapHomePage.tsx +++ b/src/pages/map/MapHomePage.tsx @@ -1,4 +1,4 @@ -import { type JSX, lazy, Suspense, useMemo, useState } from "react"; +import { type JSX, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Navigate } from "react-router-dom"; import { BottomNavigationBar } from "@/components/common/BottomNavigationBar"; @@ -9,6 +9,13 @@ import { MapSearchOverlay } from "@/components/map/MapSearchOverlay"; import type { PlaceFilterData } from "@/features/map/api/place-taxonomy-types"; import { useMapSearchFilters } from "@/features/map/hooks/use-map-search-filters"; import { usePlaceFilterData } from "@/features/map/hooks/use-place-filter-data"; +import { + buildMapSearchSuggestions, + findMapLocationMatchedPlaces, + findMapSearchCenter, + findMapSearchMatchedPlaces, + isMapLocationSearch, +} from "@/features/map/utils/map-search"; import { useBottomNavController } from "@/hooks/use-bottom-nav-controller"; import { APP_ROUTES } from "@/shared/config/routes"; import { resolveSavedPlacesBusinessHours, useKoreanNow } from "@/shared/lib/place-business-hours"; @@ -17,7 +24,8 @@ import { MAP_SEARCH_PLACEHOLDER, SAVED_PLACE_MOCKS, } from "@/shared/mocks/place-mocks"; -import type { RoomFriend } from "@/shared/types/map-home"; +import type { MapCoordinate, RoomFriend, SavedPlace } from "@/shared/types/map-home"; +import { PLACE_DETAIL_OPEN_EVENT } from "@/store/place-detail-store"; import type { SelectedRoom } from "@/store/room-selection-store"; import { useRoomSelectionStore } from "@/store/room-selection-store"; @@ -35,6 +43,15 @@ const KakaoMapView = lazy(() => import("@/components/map/KakaoMapView").then((module) => ({ default: module.KakaoMapView })), ); +const MAP_SEARCH_HISTORY_STATE_KEY = "mapSearch"; + +type MapViewport = { + center: MapCoordinate; + fitBoundsPlaces: SavedPlace[]; + geocodeKeyword: string; + key: string; +}; + type MapHomePageContentProps = { defaultFilterPanelOpen?: boolean; filterDataOverride?: PlaceFilterData | null; @@ -47,6 +64,16 @@ export function MapHomePageContent({ const selectedRoom = useRoomSelectionStore((s) => s.selectedRoom); const { toastMessage, toastPlacement, handleSelectBottomNav } = useBottomNavController(); const [friendMenuOpen, setFriendMenuOpen] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [selectedSearchPlaceId, setSelectedSearchPlaceId] = useState(null); + const [isSearchSuggestionDismissed, setIsSearchSuggestionDismissed] = useState(false); + const [mapViewport, setMapViewport] = useState({ + center: MAP_INITIAL_CENTER, + fitBoundsPlaces: [], + geocodeKeyword: "", + key: "initial", + }); + const searchHistoryPushedRef = useRef(false); const now = useKoreanNow(); const mapTitle = selectedRoom ? selectedRoom.name : "데이트 지도"; const places = useMemo(() => resolveSavedPlacesBusinessHours(SAVED_PLACE_MOCKS, now), [now]); @@ -60,8 +87,8 @@ export function MapHomePageContent({ } = usePlaceFilterData(filterDataOverride); const { - keyword, - setKeyword, + keyword: appliedKeyword, + setKeyword: setAppliedKeyword, activeCategories, focusedCategory, toggleCategory, @@ -82,6 +109,145 @@ export function MapHomePageContent({ () => (selectedRoom ? roomFriendsForFab(selectedRoom) : []), [selectedRoom], ); + const searchSuggestions = useMemo( + () => buildMapSearchSuggestions(places, searchInput), + [places, searchInput], + ); + const isSearchSuggestionsOpen = searchInput.trim().length > 0 && !isSearchSuggestionDismissed; + const clearSearchKeepViewport = useCallback(() => { + if (!appliedKeyword && !searchInput && !selectedSearchPlaceId) { + return; + } + + setSelectedSearchPlaceId(null); + setIsSearchSuggestionDismissed(true); + setSearchInput(""); + setAppliedKeyword(""); + }, [appliedKeyword, searchInput, selectedSearchPlaceId, setAppliedKeyword]); + + useEffect(() => { + const handlePopState = (event: PopStateEvent) => { + if (!searchHistoryPushedRef.current) { + return; + } + + if (event.state?.[MAP_SEARCH_HISTORY_STATE_KEY]) { + return; + } + + searchHistoryPushedRef.current = false; + clearSearchKeepViewport(); + }; + + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, [clearSearchKeepViewport]); + + const pushSearchHistory = useCallback(() => { + if (searchHistoryPushedRef.current) { + window.history.replaceState({ [MAP_SEARCH_HISTORY_STATE_KEY]: true }, ""); + return; + } + + window.history.pushState({ [MAP_SEARCH_HISTORY_STATE_KEY]: true }, ""); + searchHistoryPushedRef.current = true; + }, []); + + const handleKeywordChange = useCallback((nextKeyword: string) => { + setSelectedSearchPlaceId(null); + setIsSearchSuggestionDismissed(nextKeyword.trim().length === 0); + setSearchInput(nextKeyword); + }, []); + + const handleSubmitSearch = useCallback(() => { + const nextKeyword = searchInput.trim(); + setSelectedSearchPlaceId(null); + setIsSearchSuggestionDismissed(true); + + if (!nextKeyword) { + searchHistoryPushedRef.current = false; + setSearchInput(""); + setAppliedKeyword(""); + setMapViewport({ + center: MAP_INITIAL_CENTER, + fitBoundsPlaces: [], + geocodeKeyword: "", + key: `initial-${Date.now()}`, + }); + return; + } + + const isLocationSearch = isMapLocationSearch(nextKeyword); + const matchedPlaces = isLocationSearch + ? findMapLocationMatchedPlaces(places, nextKeyword) + : findMapSearchMatchedPlaces(places, nextKeyword); + const shouldUseKakaoSearch = matchedPlaces.length === 0; + const nextCenter = isLocationSearch + ? findMapSearchCenter(places, nextKeyword, mapViewport.center) + : matchedPlaces.length === 1 + ? { + latitude: matchedPlaces[0].latitude, + longitude: matchedPlaces[0].longitude, + } + : findMapSearchCenter(places, nextKeyword, mapViewport.center); + + setAppliedKeyword(isLocationSearch || shouldUseKakaoSearch ? "" : nextKeyword); + setMapViewport({ + center: nextCenter, + fitBoundsPlaces: matchedPlaces, + geocodeKeyword: shouldUseKakaoSearch ? nextKeyword : "", + key: `${nextKeyword}-${Date.now()}`, + }); + pushSearchHistory(); + }, [mapViewport.center, places, pushSearchHistory, searchInput, setAppliedKeyword]); + + const handleCloseTagPanel = useCallback(() => { + setIsSearchSuggestionDismissed(true); + closeTagPanel(); + }, [closeTagPanel]); + + const handleOpenTagPanel = useCallback( + (...params: Parameters) => { + setIsSearchSuggestionDismissed(true); + toggleCategory(...params); + }, + [toggleCategory], + ); + + const handleSelectSearchPlace = useCallback( + (placeId: string) => { + const place = places.find((item) => item.id === placeId); + if (!place) { + return; + } + + setSelectedSearchPlaceId(place.id); + setIsSearchSuggestionDismissed(true); + setSearchInput(place.name); + setAppliedKeyword(place.name); + setMapViewport({ + center: { latitude: place.latitude, longitude: place.longitude }, + fitBoundsPlaces: [place], + geocodeKeyword: "", + key: `${place.id}-${Date.now()}`, + }); + pushSearchHistory(); + window.dispatchEvent( + new CustomEvent(PLACE_DETAIL_OPEN_EVENT, { + detail: { placeId: place.id }, + }), + ); + }, + [places, pushSearchHistory, setAppliedKeyword], + ); + + const handleMapClick = useCallback(() => { + clearSearchKeepViewport(); + setIsSearchSuggestionDismissed(true); + handleCloseTagPanel(); + }, [clearSearchKeepViewport, handleCloseTagPanel]); if (!selectedRoom) { return ; @@ -96,7 +262,11 @@ export function MapHomePageContent({ @@ -104,8 +274,12 @@ export function MapHomePageContent({
diff --git a/src/pages/room/RoomMainPage.tsx b/src/pages/room/RoomMainPage.tsx index b1197f2..712aa21 100644 --- a/src/pages/room/RoomMainPage.tsx +++ b/src/pages/room/RoomMainPage.tsx @@ -120,63 +120,63 @@ export default function RoomMainPage() { return ( <> } - fab={} - bottomNav={} - > - - {isRoomActionModalLoaded ? ( - - - - ) : null} - {isEditRoomModalLoaded ? ( - - - - ) : null} - {isInviteCodeModalLoaded ? ( - - - - ) : null} - {isLeaveRoomModalLoaded ? ( - - - - ) : null} - {isLinkAddModalLoaded ? ( - - - - ) : null} - {isRoomAddModalLoaded ? ( - - - - ) : null} + header={} + fab={} + bottomNav={} + > + + {isRoomActionModalLoaded ? ( + + + + ) : null} + {isEditRoomModalLoaded ? ( + + + + ) : null} + {isInviteCodeModalLoaded ? ( + + + + ) : null} + {isLeaveRoomModalLoaded ? ( + + + + ) : null} + {isLinkAddModalLoaded ? ( + + + + ) : null} + {isRoomAddModalLoaded ? ( + + + + ) : null} diff --git a/src/shared/config/navigation.ts b/src/shared/config/navigation.ts index a4e8a28..ce81d18 100644 --- a/src/shared/config/navigation.ts +++ b/src/shared/config/navigation.ts @@ -10,7 +10,11 @@ export const BOTTOM_NAV_ROUTE_BY_ID: Record = { mypage: APP_ROUTES.mypage, }; -export const ROOM_SCOPED_BOTTOM_NAV_IDS = ["list", "map", "course"] as const satisfies readonly BottomNavId[]; +export const ROOM_SCOPED_BOTTOM_NAV_IDS = [ + "list", + "map", + "course", +] as const satisfies readonly BottomNavId[]; export function isRoomScopedBottomNav(id: BottomNavId): boolean { return ROOM_SCOPED_BOTTOM_NAV_IDS.includes(id as (typeof ROOM_SCOPED_BOTTOM_NAV_IDS)[number]); diff --git a/src/shared/lib/kakao-map-sdk.ts b/src/shared/lib/kakao-map-sdk.ts index 0ca7891..248f22e 100644 --- a/src/shared/lib/kakao-map-sdk.ts +++ b/src/shared/lib/kakao-map-sdk.ts @@ -20,6 +20,42 @@ export type KakaoMarkerImage = { export type KakaoMapInstance = { setCenter: (position: KakaoLatLng) => void; + setBounds: (bounds: KakaoLatLngBounds) => void; + setLevel: (level: number) => void; +}; + +export type KakaoLatLngBounds = { + extend: (position: KakaoLatLng) => void; +}; + +export type KakaoGeocodeResult = { + x: string; + y: string; + place_name?: string; + address_name?: string; + road_address_name?: string; +}; + +export type KakaoGeocoder = { + addressSearch: ( + keyword: string, + callback: (results: KakaoGeocodeResult[], status: string) => void, + ) => void; +}; + +export type KakaoPlaces = { + keywordSearch: ( + keyword: string, + callback: (results: KakaoGeocodeResult[], status: string) => void, + ) => void; +}; + +export type KakaoServices = { + Status: { + OK: string; + }; + Geocoder: new () => KakaoGeocoder; + Places: new () => KakaoPlaces; }; type KakaoMapOptions = { @@ -43,12 +79,22 @@ export type KakaoMarker = { }; type KakaoEvent = { - addListener: (target: KakaoMarker, eventName: string, handler: () => void) => void; + addListener: ( + target: KakaoMarker | KakaoMapInstance, + eventName: string, + handler: () => void, + ) => void; + removeListener: ( + target: KakaoMarker | KakaoMapInstance, + eventName: string, + handler: () => void, + ) => void; }; export type KakaoMaps = { load: (callback: () => void) => void; LatLng: new (latitude: number, longitude: number) => KakaoLatLng; + LatLngBounds: new () => KakaoLatLngBounds; Size: new (width: number, height: number) => KakaoSize; Point: new (x: number, y: number) => KakaoPoint; Map: new (container: HTMLElement, options: KakaoMapOptions) => KakaoMapInstance; @@ -59,6 +105,7 @@ export type KakaoMaps = { ) => KakaoMarkerImage; Marker: new (options: KakaoMarkerOptions) => KakaoMarker; event: KakaoEvent; + services?: KakaoServices; }; export type KakaoNamespace = { @@ -77,9 +124,14 @@ function getWindowKakao() { return window.kakao; } +function hasServicesLibrary(kakao: KakaoNamespace | undefined) { + return Boolean(kakao?.maps.services); +} + function buildSdkScriptUrl(appKey: string) { const query = new URLSearchParams({ appkey: appKey, + libraries: "services", autoload: "false", }); return `${KAKAO_MAP_SDK_URL}?${query.toString()}`; @@ -88,7 +140,28 @@ function buildSdkScriptUrl(appKey: string) { function getOrCreateSdkScript(appKey: string) { const existingScript = document.getElementById(KAKAO_MAP_SCRIPT_ID); if (existingScript instanceof HTMLScriptElement) { - return existingScript; + const existingScriptUrl = new URL(existingScript.src); + if (!existingScriptUrl.searchParams.get("libraries")?.includes("services")) { + existingScript.remove(); + window.kakao = undefined; + } else { + return existingScript; + } + } + + const duplicatedScript = Array.from(document.scripts).find( + (script) => + script.src.startsWith(KAKAO_MAP_SDK_URL) && + !new URL(script.src).searchParams.get("libraries")?.includes("services"), + ); + if (duplicatedScript) { + duplicatedScript.remove(); + window.kakao = undefined; + } + + const existingServicesScript = document.getElementById(KAKAO_MAP_SCRIPT_ID); + if (existingServicesScript instanceof HTMLScriptElement) { + return existingServicesScript; } const script = document.createElement("script"); @@ -124,9 +197,15 @@ export function loadKakaoMapSdk(appKey: string): Promise { const existingKakao = getWindowKakao(); if (existingKakao?.maps) { - return new Promise((resolve, reject) => { - resolveKakaoMapsOrThrow(resolve, reject); - }); + if (!hasServicesLibrary(existingKakao)) { + document.getElementById(KAKAO_MAP_SCRIPT_ID)?.remove(); + window.kakao = undefined; + sdkLoadPromise = null; + } else { + return new Promise((resolve, reject) => { + resolveKakaoMapsOrThrow(resolve, reject); + }); + } } if (sdkLoadPromise) { diff --git a/src/shared/mocks/place-mocks.ts b/src/shared/mocks/place-mocks.ts index ead08e0..3e18827 100644 --- a/src/shared/mocks/place-mocks.ts +++ b/src/shared/mocks/place-mocks.ts @@ -77,6 +77,117 @@ export const SAVED_PLACE_MOCKS: SavedPlace[] = [ }, }, + { + id: "restaurant-4", + name: "페어링테이블", + category: "맛집", + tagKeys: ["맛집-양식"], + latitude: 35.8699, + longitude: 128.5963, + address: "대구 중구 동성로2길 4-3", + reelsUrl: null, + businessHours: { + holidayNotice: "운영시간 변동 가능", + weeklySchedule: [ + { dayOfWeek: 1, openTime: "11:30", closeTime: "21:30" }, + { dayOfWeek: 2, openTime: "11:30", closeTime: "21:30" }, + { dayOfWeek: 3, openTime: "11:30", closeTime: "21:30" }, + { dayOfWeek: 4, openTime: "11:30", closeTime: "21:30" }, + { dayOfWeek: 5, openTime: "11:30", closeTime: "22:00" }, + { dayOfWeek: 6, openTime: "11:30", closeTime: "22:00" }, + { dayOfWeek: 0, openTime: "11:30", closeTime: "21:00" }, + ], + }, + }, + { + id: "restaurant-5", + name: "갓잇 대구수성못점", + category: "맛집", + tagKeys: ["맛집-양식"], + latitude: 35.8279, + longitude: 128.6175, + address: "대구 수성구 수성못2길 15 1층", + reelsUrl: null, + businessHours: { + holidayNotice: "연중무휴", + weeklySchedule: [ + { dayOfWeek: 1, openTime: "11:00", closeTime: "21:30" }, + { dayOfWeek: 2, openTime: "11:00", closeTime: "21:30" }, + { dayOfWeek: 3, openTime: "11:00", closeTime: "21:30" }, + { dayOfWeek: 4, openTime: "11:00", closeTime: "21:30" }, + { dayOfWeek: 5, openTime: "11:00", closeTime: "21:30" }, + { dayOfWeek: 6, openTime: "11:00", closeTime: "21:30" }, + { dayOfWeek: 0, openTime: "11:00", closeTime: "21:30" }, + ], + }, + }, + { + id: "restaurant-6", + name: "써브웨이 대구동성로점", + category: "맛집", + tagKeys: ["맛집-양식"], + latitude: 35.8707, + longitude: 128.5962, + address: "대구광역시 중구 국채보상로 598", + reelsUrl: null, + businessHours: { + holidayNotice: "운영시간 변동 가능", + weeklySchedule: [ + { dayOfWeek: 1, openTime: "08:00", closeTime: "22:00" }, + { dayOfWeek: 2, openTime: "08:00", closeTime: "22:00" }, + { dayOfWeek: 3, openTime: "08:00", closeTime: "22:00" }, + { dayOfWeek: 4, openTime: "08:00", closeTime: "22:00" }, + { dayOfWeek: 5, openTime: "08:00", closeTime: "22:00" }, + { dayOfWeek: 6, openTime: "09:00", closeTime: "21:00" }, + { dayOfWeek: 0, openTime: "09:00", closeTime: "21:00" }, + ], + }, + }, + { + id: "restaurant-7", + name: "써브웨이 송도센트럴파크점", + category: "맛집", + tagKeys: ["맛집-양식"], + latitude: 37.3935, + longitude: 126.639, + address: "인천광역시 연수구 센트럴로 194", + reelsUrl: null, + businessHours: { + holidayNotice: "연중무휴", + weeklySchedule: [ + { dayOfWeek: 1, openTime: "08:00", closeTime: "22:00" }, + { dayOfWeek: 2, openTime: "08:00", closeTime: "22:00" }, + { dayOfWeek: 3, openTime: "08:00", closeTime: "22:00" }, + { dayOfWeek: 4, openTime: "08:00", closeTime: "22:00" }, + { dayOfWeek: 5, openTime: "08:00", closeTime: "22:00" }, + { dayOfWeek: 6, openTime: "08:00", closeTime: "22:00" }, + { dayOfWeek: 0, openTime: "08:00", closeTime: "22:00" }, + ], + }, + }, + { + id: "restaurant-8", + name: "써브웨이 외대점", + category: "맛집", + tagKeys: ["맛집-양식"], + latitude: 37.5969, + longitude: 127.0609, + address: "서울 동대문구 이문로 116", + reelsUrl: null, + businessHours: { + holidayNotice: "운영시간 변동 가능", + weeklySchedule: [ + { dayOfWeek: 1, openTime: "08:30", closeTime: "22:30" }, + { dayOfWeek: 2, openTime: "08:30", closeTime: "22:30" }, + { dayOfWeek: 3, openTime: "08:30", closeTime: "22:30" }, + { dayOfWeek: 4, openTime: "08:30", closeTime: "22:30" }, + { dayOfWeek: 5, openTime: "08:30", closeTime: "22:30" }, + { dayOfWeek: 6, openTime: "09:00", closeTime: "21:00" }, + { dayOfWeek: 0, openTime: "09:00", closeTime: "21:00" }, + ], + }, + }, + // 카페 { id: "cafe-1",