diff --git a/src/components/map/Map.search-rank.test.tsx b/src/components/map/Map.search-rank.test.tsx index 1760aa39..5d2c006d 100644 --- a/src/components/map/Map.search-rank.test.tsx +++ b/src/components/map/Map.search-rank.test.tsx @@ -46,6 +46,7 @@ vi.mock("@/contexts/MobileViewContext", () => ({ })); vi.mock("@/hooks/usePlacesSearch", () => ({ fetchPlacesPageWithPhotos: boundary.page, + fetchPlacesAreaPage: vi.fn(), })); vi.mock("@/lib/analytics/track", () => ({ AnalyticsEvents: { search: "view_search_results", viewPlace: "view_place" }, diff --git a/src/components/map/Map.search-selection.test.tsx b/src/components/map/Map.search-selection.test.tsx index 2b7ede63..9adf5e06 100644 --- a/src/components/map/Map.search-selection.test.tsx +++ b/src/components/map/Map.search-selection.test.tsx @@ -50,6 +50,7 @@ vi.mock("@/contexts/MobileViewContext", () => ({ })); vi.mock("@/hooks/usePlacesSearch", () => ({ fetchPlacesPageWithPhotos: boundary.page, + fetchPlacesAreaPage: vi.fn(), })); vi.mock("@/lib/analytics/track", () => ({ AnalyticsEvents: { search: "search" }, diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index 3abf7dcd..78270004 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -39,6 +39,7 @@ import { import { MAP_DETAIL_PANEL_COVER_WIDTH_PX } from "@/lib/layout-tokens"; import { isPlanPath } from "@/lib/layout/mainChromeLayoutWidth"; import { isMobileMapPathname } from "@/lib/mobile-view"; +import { settleSearchSnapshotAfterMove } from "@/lib/map-viewport-commit"; import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius"; import { useMapCenterStore } from "@/stores/map-center-store"; import { useSearchRecenterStore } from "@/stores/search-recenter-store"; @@ -194,6 +195,7 @@ function MapBootstrapSync({ mapCenter: { lat, lng }, zoom: mapZoom, radiusMeters, + bounds: bounds?.toJSON() ?? null, }); }, [map, ready, center.lat, center.lng, zoom, roomId, setMapCamera]); @@ -291,7 +293,9 @@ function MapSearchResultPins() { const selectedPlaceId = isMobileDevice && !selectedPlace ? searchSelectedId ?? "" : selectedPlace?.googlePlaceId?.trim() ?? ""; useEffect(() => { - if (map && preview) panToWithinWorld(map, preview.location); + if (!map || !preview) return; + panToWithinWorld(map, preview.location); + return settleSearchSnapshotAfterMove(map); }, [map, preview]); // 전체 검색 결과가 준비되면 첫 추천 대신 실제 결과들이 보이도록 맞춘다. @@ -305,6 +309,7 @@ function MapSearchResultPins() { items.forEach((item) => bounds.extend(item.location)); map.fitBounds(bounds, 80); } + return settleSearchSnapshotAfterMove(map); }, [map, isMobileDevice, pinsFocus, kind, status, items]); if (pinsFocus !== "search") return null; @@ -396,6 +401,8 @@ export default function TripMap({ ); const [rating, setRating] = useState("all"); const [openNow, setOpenNow] = useState("all"); + // 카테고리 검색이 실패하면 「현 위치에서 검색」 버튼이 「다시 시도」로 남는다 + const [discoverFailed, setDiscoverFailed] = useState(false); const [showBookmarkPins, setShowBookmarkPins] = useState(true); // Text search takes over the map at request start, including autocomplete selection. @@ -461,6 +468,7 @@ export default function TripMap({ mapCenter: { lat: center.lat, lng: center.lng }, zoom, radiusMeters, + bounds, }); const rid = @@ -595,7 +603,7 @@ export default function TripMap({ {routeView ? : ( <> - + @@ -649,7 +657,7 @@ export default function TripMap({ {!bootstrap.ready || routeView ? null : ( <> - {planRouteMode ? null : } + {planRouteMode ? null : } {isMobileDevice ? (
{onOpenRouteView ? ( @@ -770,7 +778,9 @@ function MobileSearchSelection() { const lat = item?.location.lat; const lng = item?.location.lng; useEffect(() => { - if (map && focus === "search" && !selectedPlace && lat != null && lng != null) panToWithinWorld(map, { lat, lng }); + if (!map || focus !== "search" || selectedPlace || lat == null || lng == null) return; + panToWithinWorld(map, { lat, lng }); + return settleSearchSnapshotAfterMove(map); }, [map, focus, selectedPlace, selectedId, lat, lng]); if (!item || focus !== "search" || selectedPlace) return null; return event.stop()}>; diff --git a/src/components/map/MapDiscoverPlaces.tsx b/src/components/map/MapDiscoverPlaces.tsx index ef6de3b3..d8cf01df 100644 --- a/src/components/map/MapDiscoverPlaces.tsx +++ b/src/components/map/MapDiscoverPlaces.tsx @@ -63,6 +63,8 @@ export type MapDiscoverPlacesProps = { selectedCategoryId: string | null; rating: RatingValue; openNow: OpenValue; + /** 검색이 실패하면 true, 새 검색을 시작하면 false. 바뀌지 않는 함수(useState의 set)를 넘긴다 */ + onSearchFailedChange: (failed: boolean) => void; }; function DiscoverMarkerPin({ @@ -115,6 +117,7 @@ export function MapDiscoverPlaces({ selectedCategoryId, rating, openNow, + onSearchFailedChange, }: MapDiscoverPlacesProps) { const map = useMap(); const placesLib = useMapsLibrary("places"); @@ -140,6 +143,7 @@ export function MapDiscoverPlaces({ writeEpoch: number, ) => { const seq = ++searchSeqRef.current; + onSearchFailedChange(false); if (!placesLib) { if ( @@ -237,9 +241,10 @@ export function MapDiscoverPlaces({ return; } setMarkers([]); + onSearchFailedChange(true); } }, - [placesLib], + [placesLib, onSearchFailedChange], ); /** Search가 맵 핀 소유 시 디스커버 마커 비우기·비행 응답 무효화 */ diff --git a/src/components/map/MapPlaceSearch.test.tsx b/src/components/map/MapPlaceSearch.test.tsx index 63a13fd2..ffd51012 100644 --- a/src/components/map/MapPlaceSearch.test.tsx +++ b/src/components/map/MapPlaceSearch.test.tsx @@ -30,6 +30,7 @@ vi.mock("@/contexts/SelectedPlaceContext", () => ({ })); vi.mock("@/hooks/usePlacesSearch", () => ({ fetchPlacesPageWithPhotos: boundary.page, + fetchPlacesAreaPage: vi.fn(), })); vi.mock("@/lib/analytics/track", () => ({ AnalyticsEvents: { search: "search" }, @@ -84,9 +85,7 @@ it("specific Details failure retry returns to that input with a fresh session an (buttons[1] as HTMLButtonElement).click(); }); expect(usePlaceSearchStore.getState().status).toBe("error"); - const retry = [...host.querySelectorAll("button")].find( - (button) => button.textContent === "다시 시도", - )!; + const retry = host.querySelector('button[aria-label="다시 시도"]')!; await act(async () => retry.click()); expect(boundary.page).not.toHaveBeenCalled(); expect(document.activeElement).toBe(input); @@ -106,3 +105,21 @@ it("specific Details failure retry returns to that input with a fresh session an expect(boundary.page).not.toHaveBeenCalled(); expect(usePlaceSearchStore.getState().selectedId).toBe("chosen"); }); +// 버튼으로 한 「현 위치에서 검색」의 결과 없음은 검색창 아래가 아니라 버튼 자리에서 알린다 +it("search here no results stay out of the search box", async () => { + useSessionStore.setState({ currentRoomId: "room" }); + useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 } }); + usePlaceSearchStore.getState().clear(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + cleanup = async () => { + await act(async () => root.unmount()); + host.remove(); + }; + await act(async () => root.render()); + await act(async () => usePlaceSearchStore.setState({ searchMode: "map_recenter", status: "done", items: [] })); + expect(host.textContent).not.toContain("검색 결과가 없어요"); + await act(async () => usePlaceSearchStore.setState({ searchMode: "text" })); + expect(host.textContent).toContain("검색 결과가 없어요"); +}); diff --git a/src/components/map/MapPlaceSearch.tsx b/src/components/map/MapPlaceSearch.tsx index 32954c3e..dd528959 100644 --- a/src/components/map/MapPlaceSearch.tsx +++ b/src/components/map/MapPlaceSearch.tsx @@ -2,6 +2,7 @@ import { useRef } from "react"; import { flushSync } from "react-dom"; +import { RefreshIcon } from "@/assets/icons"; import { useMobileMapSearchStore } from "@/stores/mobile-map-search-store"; import { useSearchParams } from "next/navigation"; import { MapPlaceSearchInput } from "@/components/search/MapPlaceSearchInput"; @@ -26,6 +27,7 @@ export function MapPlaceSearch({ mobile, hidden = false }: Readonly<{ const items = usePlaceSearchStore((s) => s.items); const inputRef = useRef(null); const confirmationKind = usePlaceSearchStore((s) => s.confirmationKind); + const searchMode = usePlaceSearchStore((s) => s.searchMode); const query = usePlaceSearchStore((s) => s.query); const focus = useMapPinsFocusStore((s) => s.focus); const { selectedPlace } = useSelectedPlace(); @@ -44,8 +46,9 @@ export function MapPlaceSearch({ mobile, hidden = false }: Readonly<{ } void confirm({ query, kind: "whole", session: null, generation: 0 }); }; - const hasFeedback = status === "pending" || status === "error" || - (status === "done" && !items.length); + // 「현 위치에서 검색」의 실패·결과 없음은 검색창이 아니라 그 버튼 자리에서 알린다(MapSearchHereButton) + const hasFeedback = status === "pending" || (searchMode === "text" && + (status === "error" || (status === "done" && !items.length))); const feedback = hasFeedback ? ( 0} retry={retry} /> ) : null; @@ -63,7 +66,7 @@ export function MapPlaceSearch({ mobile, hidden = false }: Readonly<{