Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
12 commits
Select commit Hold shift + click to select a range
9228f53
fix(places): 자동완성 요청을 취소하는 모든 경로에서 대기 타이머와 늦게 온 응답 버리기
KangShinGyu98 Nov 4, 2026
498c900
fix(places): 모두검색을 자동완성 첫 후보 대신 지도 중심에서 검색
KangShinGyu98 Nov 3, 2026
6e2573b
fix(places): 현 위치에서 검색은 화면 안 결과만 보여 주기
KangShinGyu98 Nov 3, 2026
ddb2fda
fix(map): 검색 결과로 지도가 움직인 뒤 '현 위치에서 검색' 버튼이 바로 뜨지 않게
KangShinGyu98 Nov 3, 2026
33de1fd
fix(map): 검색 실패 안내를 '검색에 실패했어요.'와 다시 시도 아이콘으로
KangShinGyu98 Nov 4, 2026
9ce3c5f
fix(places): 자동완성 실패 안내에 오류 원문 대신 기본 안내 보여 주기
KangShinGyu98 Nov 4, 2026
984d78d
fix(map): 검색창에 다시 들어가면 결과 없음·실패 안내를 숨기기
KangShinGyu98 Nov 4, 2026
814213e
fix(map): '현 위치에서 검색'의 실패·결과 없음은 그 버튼 자리에서 알리기
KangShinGyu98 Nov 4, 2026
5d4e2a4
fix(places): '현 위치에서 검색'은 Google에 지금 화면 사각형 안만 찾도록 요청
KangShinGyu98 Nov 4, 2026
68f492d
fix(map): '검색 결과가 없어요' 안내를 '현 위치에서 검색' 버튼과 같은 모양으로
KangShinGyu98 Nov 4, 2026
cca925f
fix(map): 카테고리 '현 위치에서 검색'이 실패하면 버튼이 '다시 시도'로 남게
KangShinGyu98 Nov 4, 2026
8d24288
Merge remote-tracking branch 'origin/dev' into fix/place-search-region
KangShinGyu98 Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/map/Map.search-rank.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
1 change: 1 addition & 0 deletions src/components/map/Map.search-selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
18 changes: 14 additions & 4 deletions src/components/map/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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]);

// 전체 검색 결과가 준비되면 첫 추천 대신 실제 결과들이 보이도록 맞춘다.
Expand All @@ -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;
Expand Down Expand Up @@ -396,6 +401,8 @@ export default function TripMap({
);
const [rating, setRating] = useState<RatingValue>("all");
const [openNow, setOpenNow] = useState<OpenValue>("all");
// 카테고리 검색이 실패하면 「현 위치에서 검색」 버튼이 「다시 시도」로 남는다
const [discoverFailed, setDiscoverFailed] = useState(false);
const [showBookmarkPins, setShowBookmarkPins] = useState(true);

// Text search takes over the map at request start, including autocomplete selection.
Expand Down Expand Up @@ -461,6 +468,7 @@ export default function TripMap({
mapCenter: { lat: center.lat, lng: center.lng },
zoom,
radiusMeters,
bounds,
});

const rid =
Expand Down Expand Up @@ -595,7 +603,7 @@ export default function TripMap({
{routeView ? <RouteViewMarkers routeView={routeView} /> : (
<>
<PlanItineraryMapRoutes />
<MapDiscoverPlaces selectedCategoryId={selectedCategoryId} rating={rating} openNow={openNow} />
<MapDiscoverPlaces selectedCategoryId={selectedCategoryId} rating={rating} openNow={openNow} onSearchFailedChange={setDiscoverFailed} />
<MapBookmarkPins roomId={currentRoomId} enabled={showBookmarkPins}
hiddenNormalizedPlaceIds={isPlanPage ? planStopPlaceIds : undefined} />
<MapSearchResultPins />
Expand Down Expand Up @@ -649,7 +657,7 @@ export default function TripMap({

{!bootstrap.ready || routeView ? null : (
<>
{planRouteMode ? null : <MapSearchHereButton discoverCategoryId={selectedCategoryId} />}
{planRouteMode ? null : <MapSearchHereButton discoverCategoryId={selectedCategoryId} discoverFailed={discoverFailed} />}
{isMobileDevice ? (
<div className="pointer-events-none absolute bottom-6 left-4 z-[16] flex flex-col items-center gap-3">
{onOpenRouteView ? (
Expand Down Expand Up @@ -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 <AdvancedMarker position={item.location} onClick={(event) => event.stop()}><span className="block text-primary drop-shadow-md"><MapPinIcon size={MAP_PIN_SELECTED_DISPLAY_SIZE_PX} {...mapPinBodyBorderProps} /></span></AdvancedMarker>;
Expand Down
7 changes: 6 additions & 1 deletion src/components/map/MapDiscoverPlaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export type MapDiscoverPlacesProps = {
selectedCategoryId: string | null;
rating: RatingValue;
openNow: OpenValue;
/** 검색이 실패하면 true, 새 검색을 시작하면 false. 바뀌지 않는 함수(useState의 set)를 넘긴다 */
onSearchFailedChange: (failed: boolean) => void;
};

function DiscoverMarkerPin({
Expand Down Expand Up @@ -115,6 +117,7 @@ export function MapDiscoverPlaces({
selectedCategoryId,
rating,
openNow,
onSearchFailedChange,
}: MapDiscoverPlacesProps) {
const map = useMap();
const placesLib = useMapsLibrary("places");
Expand All @@ -140,6 +143,7 @@ export function MapDiscoverPlaces({
writeEpoch: number,
) => {
const seq = ++searchSeqRef.current;
onSearchFailedChange(false);

if (!placesLib) {
if (
Expand Down Expand Up @@ -237,9 +241,10 @@ export function MapDiscoverPlaces({
return;
}
setMarkers([]);
onSearchFailedChange(true);
}
},
[placesLib],
[placesLib, onSearchFailedChange],
);

/** Search가 맵 핀 소유 시 디스커버 마커 비우기·비행 응답 무효화 */
Expand Down
23 changes: 20 additions & 3 deletions src/components/map/MapPlaceSearch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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<HTMLButtonElement>('button[aria-label="다시 시도"]')!;
await act(async () => retry.click());
expect(boundary.page).not.toHaveBeenCalled();
expect(document.activeElement).toBe(input);
Expand All @@ -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(<MapPlaceSearch mobile={false} />));
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("검색 결과가 없어요");
});
22 changes: 14 additions & 8 deletions src/components/map/MapPlaceSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,6 +27,7 @@ export function MapPlaceSearch({ mobile, hidden = false }: Readonly<{
const items = usePlaceSearchStore((s) => s.items);
const inputRef = useRef<HTMLInputElement>(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();
Expand All @@ -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 ? (
<SearchFeedback status={status} error={error} hasItems={items.length > 0} retry={retry} />
) : null;
Expand All @@ -63,7 +66,7 @@ export function MapPlaceSearch({ mobile, hidden = false }: Readonly<{
<div
data-tutorial-target="search"
hidden={hidden}
className="pointer-events-auto relative min-w-0 max-w-[calc(100%-60px)] shrink-0"
className="group/search pointer-events-auto relative min-w-0 max-w-[calc(100%-60px)] shrink-0"
>
<MapPlaceSearchInput
key={`${scopeKey}:${initialQuery}`}
Expand All @@ -76,8 +79,9 @@ export function MapPlaceSearch({ mobile, hidden = false }: Readonly<{
onInvalidate={invalidate}
onClear={clearSearch}
/>
{/* 다시 검색하려고 검색창에 들어가면 추천 목록과 같은 자리라 안내를 숨긴다 */}
{feedback ? (
<div className="absolute right-0 top-full mt-1 w-[353px] max-w-full rounded-lg border border-border-subtle bg-fill-subtle p-3">
<div className="absolute right-0 top-full mt-1 w-[353px] max-w-full rounded-lg border border-border-subtle bg-fill-subtle p-3 group-has-[input:focus]/search:hidden">
{feedback}
</div>
) : null}
Expand All @@ -103,10 +107,12 @@ function SearchFeedback({ status, error, hasItems, retry }: Readonly<{
}>) {
if (status === "pending") return <Spinner aria-label="장소 검색 중" />;
if (status === "error") return (
<div role="alert" className="flex items-center gap-2 text-body-s-regular text-text">
<span>{error}</span>
<button type="button" className="shrink-0 cursor-pointer text-primary underline hover:opacity-80 focus-visible:outline-2 focus-visible:outline-primary" onClick={retry}>
다시 시도
<div role="alert" className="flex items-center justify-between gap-2 text-body-s-regular text-text">
<span className="min-w-0">{error}</span>
{/* 아이콘은 20px, 누르는 영역은 44px */}
<button type="button" aria-label="다시 시도" onClick={retry}
className="-m-3 grid size-11 shrink-0 cursor-pointer place-items-center text-icon-subtle hover:opacity-80 focus-visible:outline-2 focus-visible:outline-primary">
<RefreshIcon size={20} />
</button>
</div>
);
Expand Down
88 changes: 88 additions & 0 deletions src/components/map/MapSearchHereButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it } from "vitest";
import { MapSearchHereButton } from "./MapSearchHereButton";
import { useMapCenterStore } from "@/stores/map-center-store";
import { usePlaceSearchStore } from "@/stores/place-search-store";
import { useSearchRecenterStore } from "@/stores/search-recenter-store";

let root: Root, host: HTMLDivElement;
const searched = { center: { lat: 35.7, lng: 139.8 }, zoom: 15, radius: 1000 };

beforeEach(async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
usePlaceSearchStore.getState().clear();
useMapCenterStore.setState({ mapCenter: searched.center, zoom: 15 });
useSearchRecenterStore.getState().setSearchSnapshot(searched);
host = document.createElement("div");
root = createRoot(host);
await act(async () => root.render(<MapSearchHereButton discoverCategoryId={null} discoverFailed={false} />));
});
afterEach(async () => {
await act(async () => root.unmount());
useSearchRecenterStore.getState().clearSearchSnapshot();
useSearchRecenterStore.getState().clearDiscoverSnapshot();
usePlaceSearchStore.getState().clear();
});

const button = () => host.querySelector("button");
const notice = () => host.querySelector('[role="status"]');
const finishSearch = (state: Partial<ReturnType<typeof usePlaceSearchStore.getState>>) =>
act(async () => usePlaceSearchStore.setState(state));

it("shows nothing while the map shows the searched area", () => {
expect(button()).toBeNull();
});

it("turns into 다시 시도 when search here fails and asks search here again", async () => {
await finishSearch({ searchMode: "map_recenter", status: "error" });
expect(button()?.textContent).toBe("다시 시도");
const before = useSearchRecenterStore.getState().searchRecenterRequestId;
await act(async () => button()!.click());
expect(useSearchRecenterStore.getState().searchRecenterRequestId).toBe(before + 1);
});

it("a search box failure leaves the button alone", async () => {
await finishSearch({ searchMode: "text", status: "error" });
expect(button()).toBeNull();
});

it("shows 검색 결과가 없어요 until closed, and again for the next search here", async () => {
await finishSearch({ searchMode: "map_recenter", status: "done", items: [] });
expect(notice()?.textContent).toBe("검색 결과가 없어요");
await act(async () => host.querySelector<HTMLButtonElement>('button[aria-label="안내 닫기"]')!.click());
expect(notice()).toBeNull();
await act(async () => { usePlaceSearchStore.getState().invalidate(); });
await finishSearch({ searchMode: "map_recenter", status: "done", items: [] });
expect(notice()).not.toBeNull();
});

it("goes back to 현 위치에서 검색 when the map moves after a failure", async () => {
await finishSearch({ searchMode: "map_recenter", status: "error" });
await act(async () => useMapCenterStore.setState({ mapCenter: { lat: 35.8, lng: 139.8 } }));
expect(button()?.textContent).toBe("현 위치에서 검색");
});

// 카테고리 칩으로 찾은 뒤 「현 위치에서 검색」이 실패해도(결과 없음이 아니라 오류) 같은 자리에서 다시 시도한다
it("turns into 다시 시도 when the category search fails and asks the category search again", async () => {
useSearchRecenterStore.getState().setDiscoverSnapshot({
...searched,
bounds: { south: 35.69, west: 139.79, north: 35.71, east: 139.81 },
});
await act(async () => root.render(<MapSearchHereButton discoverCategoryId="restaurant" discoverFailed />));
expect(button()?.textContent).toBe("다시 시도");
const before = useSearchRecenterStore.getState().discoverRecenterRequestId;
await act(async () => button()!.click());
expect(useSearchRecenterStore.getState().discoverRecenterRequestId).toBe(before + 1);
});

it("goes back to 현 위치에서 검색 when the map moves after a category failure", async () => {
useSearchRecenterStore.getState().setDiscoverSnapshot({
...searched,
bounds: { south: 35.69, west: 139.79, north: 35.71, east: 139.81 },
});
await act(async () => root.render(<MapSearchHereButton discoverCategoryId="restaurant" discoverFailed />));
await act(async () => useMapCenterStore.setState({ mapCenter: { lat: 35.8, lng: 139.8 } }));
expect(button()?.textContent).toBe("현 위치에서 검색");
});
Loading
Loading