From 5deeb4ec4da7f020bc9c827b869d8316045c2307 Mon Sep 17 00:00:00 2001 From: minbros Date: Wed, 14 Oct 2026 15:17:15 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(search):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=A7=80=EB=8F=84=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EA=B3=BC=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자동완성과 좌표 상세 조회를 연결하고 검색 확정 경로 분리 - 공통 검색 입력과 모바일 카드 확장 및 기존 상세 정보 유지 - 비동기 경쟁 조건과 검색 순위 분석 회귀 검증 --- .../MobileRoutePlaceDetail.error.test.tsx | 54 +++ .../_components/MobileRoutePlaceDetail.tsx | 17 +- src/app/(main)/search/page.lifecycle.test.tsx | 144 +----- src/app/(main)/search/page.tsx | 308 +----------- .../CookieSettingsEntrypoints.test.tsx | 1 - src/components/layout/SideBar.tsx | 1 - .../layout/travel-navigation.test.tsx | 28 +- src/components/map/Map.search-rank.test.tsx | 443 ++++++++++++++++++ .../map/Map.search-selection.test.tsx | 297 ++++++++++++ src/components/map/Map.tsx | 44 +- src/components/map/MapDiscoverToolbar.tsx | 2 +- src/components/map/MapPlaceSearch.test.tsx | 113 +++++ src/components/map/MapPlaceSearch.tsx | 109 +++++ src/components/map/MapWithDetailPanel.tsx | 4 +- .../map/MobileMapSearch.history.test.tsx | 112 +++++ src/components/map/MobileMapSearch.tsx | 147 +++--- .../map/MobileSearchResultCarousel.test.tsx | 102 ++++ .../map/MobileSearchResultCarousel.tsx | 204 ++++++++ src/components/map/useMapPlaceSearch.test.tsx | 255 ++++++++++ src/components/map/useMapPlaceSearch.ts | 294 ++++++++++++ .../place/PlaceDetailPanel.lifecycle.test.tsx | 18 +- src/components/place/PlaceDetailPanel.tsx | 14 +- src/components/place/usePlaceDetailData.ts | 14 +- .../search/MapMobileSearchScreen.tsx | 215 +++++++++ .../search/MapPlaceSearchInput.test.tsx | 122 +++++ src/components/search/MapPlaceSearchInput.tsx | 378 +++++++++++++++ .../search/MobilePlaceSearchScreen.tsx | 11 +- src/contexts/SelectedPlaceContext.tsx | 33 +- src/lib/places/place-detail-cache.test.tsx | 91 ++++ src/lib/places/place-queries.ts | 7 +- src/lib/places/search-session.test.ts | 163 +++++++ src/lib/places/search-session.ts | 110 +++++ src/stores/place-search-store.ts | 62 +++ 33 files changed, 3386 insertions(+), 531 deletions(-) create mode 100644 src/app/(main)/map/_components/MobileRoutePlaceDetail.error.test.tsx create mode 100644 src/components/map/Map.search-rank.test.tsx create mode 100644 src/components/map/Map.search-selection.test.tsx create mode 100644 src/components/map/MapPlaceSearch.test.tsx create mode 100644 src/components/map/MapPlaceSearch.tsx create mode 100644 src/components/map/MobileMapSearch.history.test.tsx create mode 100644 src/components/map/MobileSearchResultCarousel.test.tsx create mode 100644 src/components/map/MobileSearchResultCarousel.tsx create mode 100644 src/components/map/useMapPlaceSearch.test.tsx create mode 100644 src/components/map/useMapPlaceSearch.ts create mode 100644 src/components/search/MapMobileSearchScreen.tsx create mode 100644 src/components/search/MapPlaceSearchInput.test.tsx create mode 100644 src/components/search/MapPlaceSearchInput.tsx create mode 100644 src/lib/places/place-detail-cache.test.tsx create mode 100644 src/lib/places/search-session.test.ts create mode 100644 src/lib/places/search-session.ts create mode 100644 src/stores/place-search-store.ts diff --git a/src/app/(main)/map/_components/MobileRoutePlaceDetail.error.test.tsx b/src/app/(main)/map/_components/MobileRoutePlaceDetail.error.test.tsx new file mode 100644 index 00000000..2c87683f --- /dev/null +++ b/src/app/(main)/map/_components/MobileRoutePlaceDetail.error.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; +import { MobileRoutePlaceDetail } from "./MobileRoutePlaceDetail"; +const boundary = vi.hoisted(() => ({ + tabs: vi.fn(), + retry: vi.fn(), + track: vi.fn(), +})); +vi.mock("@/hooks/useAnalyticsRoomId", () => ({ + useAnalyticsRoomId: () => "room", +})); +vi.mock("@/components/place/usePlaceDetailData", () => ({ + usePlaceDetailData: () => ({ + isLoading: false, + isError: true, + refetch: boundary.retry, + }), +})); +vi.mock("@/components/place/HeroSection", () => ({ + HeroImage: () => null, + HeroSkeleton: () => null, +})); +vi.mock("@/components/place/PlaceDetailTabs", () => ({ + PlaceDetailTabs: (props: unknown) => { + boundary.tabs(props); + return

리뷰 없음

; + }, +})); +vi.mock("@/lib/analytics/track", () => ({ + AnalyticsEvents: { viewPlace: "view_place" }, + trackAnalyticsEvent: boundary.track, +})); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +it("failed full Details is retryable and never represented as no reviews or a successful view", async () => { + const host = document.createElement("div"); + const root = createRoot(host); + await act(async () => + root.render( + , + ), + ); + expect(boundary.tabs).not.toHaveBeenCalled(); + expect(boundary.track).not.toHaveBeenCalled(); + expect(host.querySelector('[role="alert"]')).not.toBeNull(); + await act(async () => host.querySelector("button")!.click()); + expect(boundary.retry).toHaveBeenCalledTimes(1); + await act(async () => root.unmount()); +}); diff --git a/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx index ecc894c9..fe80fa98 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx @@ -8,6 +8,7 @@ import { PlaceDetailTabs } from "@/components/place/PlaceDetailTabs"; import { usePlaceDetailData } from "@/components/place/usePlaceDetailData"; import { AnalyticsEvents, trackAnalyticsEvent, type AnalyticsSource } from "@/lib/analytics/track"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; +import type { SearchRankBucket } from "@/lib/analytics/context"; import type { PlanPlace } from "@/lib/plan/types"; /** @@ -19,28 +20,31 @@ export function MobileRoutePlaceDetail({ place, active, analyticsSource = "plan", + analyticsRankBucket, }: Readonly<{ place: Pick; active: boolean; analyticsSource?: AnalyticsSource; + analyticsRankBucket?: SearchRankBucket; }>) { const analyticsRoomId = useAnalyticsRoomId(); const rawId = place.googlePlaceId?.trim() ?? ""; const googlePlaceId = rawId ? normalizeGooglePlaceResourceId(rawId) : undefined; - const { data: detail, isLoading } = usePlaceDetailData(active ? googlePlaceId : undefined); + const { data: detail, isLoading, isError, refetch } = usePlaceDetailData(active ? googlePlaceId : undefined); const category = detail?.primaryTypeDisplayName ?? place.primaryTypeDisplayName ?? ""; // 일정 상세를 본 것으로 한 번만 기록한다(장소 상세 패널과 같은 이벤트) const trackedPlaceIdRef = useRef(null); useEffect(() => { - if (!active || !googlePlaceId || isLoading || trackedPlaceIdRef.current === googlePlaceId) return; + if (!active || !googlePlaceId || isLoading || isError || trackedPlaceIdRef.current === googlePlaceId) return; trackedPlaceIdRef.current = googlePlaceId; trackAnalyticsEvent(AnalyticsEvents.viewPlace, { place_category: category, interaction_source: analyticsSource, + ...(analyticsRankBucket ? { rank_bucket: analyticsRankBucket } : {}), ...(analyticsRoomId ? { room_id: analyticsRoomId } : {}), }); - }, [active, analyticsSource, analyticsRoomId, category, googlePlaceId, isLoading]); + }, [active, analyticsSource, analyticsRankBucket, analyticsRoomId, category, googlePlaceId, isLoading, isError]); const showSkeleton = !active || (isLoading && !detail); @@ -54,7 +58,12 @@ export function MobileRoutePlaceDetail({ )} - {showSkeleton ? ( + {isError && !detail && active ? ( +
+

장소 정보를 불러오지 못했어요.

+ +
+ ) : showSkeleton ? ( diff --git a/src/app/(main)/search/page.lifecycle.test.tsx b/src/app/(main)/search/page.lifecycle.test.tsx index 6ddd9608..d3cb0dc1 100644 --- a/src/app/(main)/search/page.lifecycle.test.tsx +++ b/src/app/(main)/search/page.lifecycle.test.tsx @@ -1,139 +1,33 @@ // @vitest-environment jsdom -import { act, type ComponentProps } from "react"; +import { act } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vitest"; import SearchPage from "./page"; -import { useMapCenterStore } from "@/stores/map-center-store"; -import { useSearchRecenterStore } from "@/stores/search-recenter-store"; -import type { PlacesSearchInput } from "@/components/search/PlacesSearchInput"; const state = vi.hoisted(() => ({ - roomId: "room-a" as string | undefined, - fetching: false, - params: new URLSearchParams("q=cafe"), + mobile: false, + params: new URLSearchParams("q=원래 검색어"), replace: vi.fn(), - track: vi.fn(), - input: null as ComponentProps | null, - args: [] as unknown[], })); -vi.mock("@/hooks/useAnalyticsRoomId", () => ({ useAnalyticsRoomId: () => state.roomId })); vi.mock("next/navigation", () => ({ useSearchParams: () => state.params, useRouter: () => ({ replace: state.replace }), })); -vi.mock("@/hooks/usePlacesSearch", () => ({ - usePlacesSearch: (...args: unknown[]) => { - state.args = args; - return { items: [], pageIndex: 0, isSuccess: true, isFetching: state.fetching }; - }, -})); -vi.mock("@/components/search/PlacesSearchInput", () => ({ - PlacesSearchInput: (props: ComponentProps) => { - state.input = props; - return null; - }, -})); -vi.mock("@/components/place", () => ({ SearchResultCard: () => null })); -// 카드 클릭(장소 열기)은 이 테스트 범위 밖 — 지도 이동 훅의 라우터·세션 의존성을 끊는다 -vi.mock("@/hooks/useOpenPlaceOnMap", () => ({ useOpenPlaceOnMap: () => () => {} })); -vi.mock("@/components/layout/MainPageHeader", () => ({ - MainPageHeader: () => null, -})); -vi.mock("@/contexts/SectionWidthContext", () => ({ - SetSectionMaxWidth: () => null, -})); -vi.mock("@/lib/analytics/track", () => ({ - AnalyticsEvents: { search: "search" }, - trackAnalyticsEvent: state.track, +vi.mock("@/contexts/MobileViewContext", () => ({ + useMobileView: () => ({ isMobileDevice: state.mobile }), })); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -it("commits URL, same-text and recenter searches once; camera drags alone do not search", async () => { - useMapCenterStore.setState({ - mapCenter: null, - zoom: null, - radiusMeters: null, - }); - const root = createRoot(document.createElement("div")); - const render = async () => { +it.each([false, true])( + "keeps legacy query links by handing them to the common map search (mobile=%s)", + async (mobile) => { + state.mobile = mobile; + state.replace.mockReset(); + const host = document.createElement("div"); + const root = createRoot(host); await act(async () => root.render()); - }; - await render(); - expect(state.args[1]).toBeNull(); - await act(async () => - useMapCenterStore - .getState() - .setMapCamera({ - mapCenter: { lat: 1, lng: 2 }, - zoom: 12, - radiusMeters: 1000, - }), - ); - expect(state.args.slice(0, 4)).toEqual(["cafe", 1, 2, 1000]); - expect(state.track).toHaveBeenCalledTimes(1); - await act(async () => - useMapCenterStore - .getState() - .setMapCamera({ - mapCenter: { lat: 3, lng: 4 }, - zoom: 13, - radiusMeters: 2000, - }), - ); - expect(state.args[1]).toBe(1); - expect(state.track).toHaveBeenCalledTimes(1); - await act(async () => state.input!.onSearch("food")); - state.params = new URLSearchParams("q=food"); - await render(); - expect(state.track).toHaveBeenCalledTimes(2); - expect(state.args.slice(0, 4)).toEqual(["food", 3, 4, 2000]); - await act(async () => state.input!.onSearch("food")); - expect(state.track).toHaveBeenCalledTimes(3); - await act(async () => - useSearchRecenterStore.getState().requestSearchRecenter(), - ); - expect(state.track).toHaveBeenCalledTimes(4); - expect(state.track.mock.lastCall?.[1].search_mode).toBe("map_recenter"); - state.params = new URLSearchParams("q=park"); - await render(); - expect(state.track).toHaveBeenCalledTimes(5); - expect(state.track.mock.lastCall?.[1].search_mode).toBe("text"); - await act(async () => root.unmount()); -}); - -it("keeps the existing default radius when the camera radius is non-finite", async () => { - state.params = new URLSearchParams("q=cafe"); - useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 }, zoom: 12, radiusMeters: NaN }); - const root = createRoot(document.createElement("div")); - await act(async () => root.render()); - expect(state.args[3]).toBe(5000); - await act(async () => root.unmount()); -}); - - -it("attributes settled results to the room where that search started", async () => { - state.track.mockClear(); - state.params = new URLSearchParams("q=cafe"); - state.roomId = "room-a"; - state.fetching = true; - useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 }, zoom: 12, radiusMeters: 1000 }); - const root = createRoot(document.createElement("div")); - await act(async () => root.render()); - state.roomId = "room-b"; - state.fetching = false; - await act(async () => root.render()); - expect(state.track).toHaveBeenCalledExactlyOnceWith("search", expect.objectContaining({ room_id: "room-a" })); - await act(async () => root.unmount()); -}); - -it("does not retroactively add a room to a search that started without one", async () => { - state.track.mockClear(); - state.roomId = undefined; - state.fetching = true; - const root = createRoot(document.createElement("div")); - await act(async () => root.render()); - state.roomId = "room-b"; - state.fetching = false; - await act(async () => root.render()); - expect(state.track).toHaveBeenCalledTimes(1); - expect(state.track.mock.lastCall?.[1]).not.toHaveProperty("room_id"); - await act(async () => root.unmount()); -}); + expect(state.replace).toHaveBeenCalledWith( + `${mobile ? "/map" : "/plan"}?q=${encodeURIComponent("원래 검색어")}`, + ); + expect(host.textContent).toBe(""); + await act(async () => root.unmount()); + }, +); diff --git a/src/app/(main)/search/page.tsx b/src/app/(main)/search/page.tsx index b0d4096f..b52e4cab 100644 --- a/src/app/(main)/search/page.tsx +++ b/src/app/(main)/search/page.tsx @@ -1,306 +1,18 @@ "use client"; -import { - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { AlertCircle, Loader2, Search } from "lucide-react"; - -import { useAnalyticsRoomId } from "@/hooks/useAnalyticsRoomId"; -import { LoadingIndicator } from "@/components/loading/LoadingIndicator"; -import { SearchResultCard } from "@/components/place"; -import { SetSectionMaxWidth } from "@/contexts/SectionWidthContext"; -import { MainPageHeader } from "@/components/layout/MainPageHeader"; -import { useOpenPlaceOnMap } from "@/hooks/useOpenPlaceOnMap"; -import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius"; -import type { SearchMapSnapshot } from "@/stores/search-recenter-store"; -import { - clearActiveSearchMapPins, - placeSearchResultsToMapPins, - setActiveSearchMapPins, -} from "@/lib/active-search-map-pins"; -import { useMapCenterStore } from "@/stores/map-center-store"; -import { useSearchRecenterStore } from "@/stores/search-recenter-store"; -import { - usePlacesSearch, - type PlaceSearchResult, -} from "@/hooks/usePlacesSearch"; -import { PLACES_SEARCH_PAGE_SIZE } from "@/lib/places/placesSearchPageSize"; -import { PlacesSearchInput } from "@/components/search/PlacesSearchInput"; -import { PlacesSearchPagination } from "@/components/search/PlacesSearchPagination"; -import { bucketResultCount, bucketSearchRank } from "@/lib/analytics/context"; -import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; -import { - searchPinWriteStillValid, - useMapPinsFocusStore, -} from "@/stores/map-pins-focus-store"; +import { useMobileView } from "@/contexts/MobileViewContext"; +/** Preserve old search links without reinstating a search-only left panel. */ export default function SearchPage() { - const analyticsRoomId = useAnalyticsRoomId(); const router = useRouter(); - const searchParams = useSearchParams(); - const qParam = searchParams.get("q")?.trim() ?? ""; - - const openPlaceOnMap = useOpenPlaceOnMap(); - const mapCenter = useMapCenterStore((s) => s.mapCenter); - const searchRecenterRequestId = useSearchRecenterStore( - (s) => s.searchRecenterRequestId, - ); - - const zoom = useMapCenterStore((s) => s.zoom); - const radiusMeters = useMapCenterStore((s) => s.radiusMeters); - const viewport: SearchMapSnapshot | null = mapCenter ? { - center: mapCenter, - zoom, - radius: clampPlacesSearchRadiusMeters( - radiusMeters != null && Number.isFinite(radiusMeters) ? radiusMeters : 5000, - ), - } : null; - const [search, setSearch] = useState({ - query: qParam, - snapshot: qParam ? viewport : null, - generation: qParam && viewport ? 1 : 0, - recenterRequestId: searchRecenterRequestId, - analyticsRoomId, - mode: "text" as "text" | "map_recenter", - }); - const urlChanged = search.query !== qParam; - const recentered = search.recenterRequestId !== searchRecenterRequestId; - if (urlChanged || recentered || (qParam && !search.snapshot && viewport)) { - setSearch({ - query: qParam, - snapshot: qParam ? viewport : null, - generation: search.generation + 1, - recenterRequestId: searchRecenterRequestId, - analyticsRoomId, - mode: !urlChanged && recentered ? "map_recenter" : "text", - }); - } - const query = search.query; - const searchCoords = search.snapshot?.center ?? null; - const searchRadius = search.snapshot?.radius; - const searchGeneration = search.generation; - const lastTrackedSearchGenerationRef = useRef(0); - const searchPinsEpochRef = useRef(0); - - // Publish the committed snapshot to the map; state ownership stays in this render. - useLayoutEffect(() => { - const { clearSearchSnapshot, setSearchSnapshot } = useSearchRecenterStore.getState(); - clearActiveSearchMapPins(); - if (!search.snapshot) { - clearSearchSnapshot(); - return; - } - searchPinsEpochRef.current = useMapPinsFocusStore.getState().claimFocus("search"); - setSearchSnapshot(search.snapshot); - }, [search.snapshot, search.generation]); - - function handleSearch(q: string) { - const trimmed = q.trim(); - if (trimmed === qParam) { - setSearch({ - query: trimmed, - snapshot: trimmed ? viewport : null, - generation: search.generation + 1, - recenterRequestId: searchRecenterRequestId, - analyticsRoomId, - mode: "text", - }); - } - const params = new URLSearchParams(searchParams.toString()); - if (trimmed) params.set("q", trimmed); - else params.delete("q"); - router.replace(`/search?${params.toString()}`, { scroll: false }); - } - - const { - items, - pageIndex, - hasPreviousPage, - hasNextPage, - goToPreviousPage, - goToNextPage, - isFetching, - isError, - isSuccess, - error, - } = usePlacesSearch( - query, - searchCoords?.lat ?? null, - searchCoords?.lng ?? null, - searchCoords !== null ? searchRadius : undefined, - PLACES_SEARCH_PAGE_SIZE, - searchGeneration, - ); - - useEffect(() => { - if (searchGeneration === 0 || !isSuccess || isFetching) return; - if (lastTrackedSearchGenerationRef.current === searchGeneration) return; - if (!query.trim() || searchCoords === null) return; - - lastTrackedSearchGenerationRef.current = searchGeneration; - trackAnalyticsEvent(AnalyticsEvents.search, { - result_count_bucket: bucketResultCount(items.length), - search_mode: search.mode, - ...(search.analyticsRoomId ? { room_id: search.analyticsRoomId } : {}), - }); - }, [ - isFetching, - isSuccess, - items.length, - query, - searchCoords, - searchGeneration, - search.mode, - search.analyticsRoomId, - ]); - - const hasActiveSearch = query.trim().length > 0 && searchCoords !== null; - const showResultList = - hasActiveSearch && isSuccess && !isError && items.length > 0; - - const resultsScrollRef = useRef(null); - - useEffect(() => { - resultsScrollRef.current?.scrollTo({ top: 0 }); - }, [pageIndex]); - const showPagination = - hasActiveSearch && - isSuccess && - !isError && - (items.length > 0 || pageIndex > 0); - - const showSearchLoading = - hasActiveSearch && isFetching && items.length === 0; - + const params = useSearchParams(); + const { isMobileDevice } = useMobileView(); + const query = params.get("q")?.trim() ?? ""; + const target = `${isMobileDevice ? "/map" : "/plan"}${query ? `?q=${encodeURIComponent(query)}` : ""}`; useEffect(() => { - if (!hasActiveSearch) { - clearActiveSearchMapPins(); - useMapPinsFocusStore.getState().releaseSearchFocusIfActive(); - return; - } - - if (isFetching) { - clearActiveSearchMapPins(); - return; - } - - if (isError) { - clearActiveSearchMapPins(); - return; - } - - if (!searchPinWriteStillValid(searchPinsEpochRef.current)) return; - setActiveSearchMapPins(placeSearchResultsToMapPins(items)); - }, [hasActiveSearch, isFetching, isError, items]); - - useEffect( - () => () => { - clearActiveSearchMapPins(); - useMapPinsFocusStore.getState().releaseSearchFocusIfActive(); - useSearchRecenterStore.getState().clearSearchSnapshot(); - }, - [], - ); - - function handleCardClick(result: PlaceSearchResult, index: number) { - openPlaceOnMap(result, { - analyticsRankBucket: bucketSearchRank( - pageIndex * PLACES_SEARCH_PAGE_SIZE + index, - ), - preserveMapZoom: true, - itinerarySource: "search", - }); - } - - return ( -
- - - - - {/* 검색 입력 — focus ring이 잘리지 않도록 상·좌우 여백 */} -
- handleSearch("")} - /> -
- - {/* 결과 */} -
- {showSearchLoading && ( - - )} - - {hasActiveSearch && isError && ( -
- - - {error instanceof Error ? error.message : "검색에 실패했습니다."} - -
- )} - - {hasActiveSearch && - isSuccess && - !isError && - items.length === 0 && - pageIndex === 0 && ( -
- - 검색 결과가 없습니다. -
- )} - - {!hasActiveSearch && ( -
- - 검색어를 입력해 주세요. -
- )} - - {showResultList && ( -
-
    - {items.map((result, index) => ( -
  • - handleCardClick(result, index)} - /> -
  • - ))} -
- {showPagination ? ( - - ) : null} - {isFetching && ( -
- -
- )} -
- )} -
-
- ); + router.replace(target); + }, [router, target]); + return null; } diff --git a/src/components/analytics/CookieSettingsEntrypoints.test.tsx b/src/components/analytics/CookieSettingsEntrypoints.test.tsx index 51cada46..b67996f3 100644 --- a/src/components/analytics/CookieSettingsEntrypoints.test.tsx +++ b/src/components/analytics/CookieSettingsEntrypoints.test.tsx @@ -68,7 +68,6 @@ it("retains landing and room footers and removes only the requested navigation e expect(sidebar).not.toContain("PrivacySettingsLink"); expect(sidebar).not.toContain("member-settings"); for (const entry of [ - "search", "plan", "bookmark", "SidebarFeedbackFormButton", diff --git a/src/components/layout/SideBar.tsx b/src/components/layout/SideBar.tsx index 29c93542..0e6b015c 100644 --- a/src/components/layout/SideBar.tsx +++ b/src/components/layout/SideBar.tsx @@ -19,7 +19,6 @@ import { sidebarNavButtonClassName } from "./sidebarNavButton"; const SIDEBAR_ITEMS = [ { key: "plan", href: "/plan", label: "일정", icon: sidebarIcons.plan }, - { key: "search", href: "/search", label: "검색", icon: sidebarIcons.search }, { key: "bookmark", href: "/bookmark", label: "북마크", icon: sidebarIcons.bookmark }, { key: "cost", href: "/cost", label: "가계부", icon: sharedSidebarIcons.cost }, { key: "packing", href: "/packing", label: "준비물", icon: sidebarIcons.packing }, diff --git a/src/components/layout/travel-navigation.test.tsx b/src/components/layout/travel-navigation.test.tsx index a2f24b4a..36a2c8ec 100644 --- a/src/components/layout/travel-navigation.test.tsx +++ b/src/components/layout/travel-navigation.test.tsx @@ -57,7 +57,7 @@ beforeEach(() => { afterEach(async () => { await act(async () => root.unmount()); host.remove(); }); async function render() { await act(async () => root.render(<>
일정 본문
)); } async function control(name: "minimize" | "close") { await act(async () => host.querySelector(`[data-control="${name}"]`)!.click()); await render(); } -async function openChatFromSidebar() { await act(async () => items()[5].click()); await render(); } +async function openChatFromSidebar() { await act(async () => items()[4].click()); await render(); } function nav() { return host.querySelector('nav[aria-label="여행 주요 메뉴"],nav[aria-label="모바일 주요 메뉴"]')!; } function items() { return [...nav().querySelectorAll("a,button")]; } function active() { return [...nav().querySelectorAll('[aria-current="page"], [aria-pressed="true"]')]; } @@ -88,11 +88,11 @@ it("switches between expenses and packing inside the mobile travel tools tab", a it("desktop chat menu opens the /chat route next to the map, and route links leave it", async () => { await render(); - expect(items().map(x => x.getAttribute("aria-label") ?? x.textContent)).toEqual(["일정", "검색", "북마크", "가계부", "준비물", "채팅"]); + expect(items().map(x => x.getAttribute("aria-label") ?? x.textContent)).toEqual(["일정", "북마크", "가계부", "준비물", "채팅"]); expect(active()).toHaveLength(1); await openChatFromSidebar(); expect(state.pathname).toBe("/chat"); - expect(active()).toEqual([items()[5]]); + expect(active()).toEqual([items()[4]]); // 최대화 채팅은 `/chat` 페이지(본문)가 그린다 — 레이아웃은 지도만 옆에 둔다 expect(host.querySelector("[data-chat]")).toBeNull(); expect(host.querySelectorAll("[data-map]:not([hidden])")).toHaveLength(1); @@ -125,7 +125,7 @@ it("leaving /chat through a menu closes chat without a minimized panel", async ( await openChatFromSidebar(); state.pathname = "/bookmark"; await render(); expect(host.querySelector("[data-chat]")).toBeNull(); - expect(active()).toEqual([items()[2]]); + expect(active()).toEqual([items()[1]]); }); it.each(["minimize", "close"] as const)("%s uses /plan for direct chat entry even with unrelated browser history", async action => { history.push("https://external.example"); @@ -158,13 +158,13 @@ it("closing the chat on /chat returns to the previous route; reopening comes bac }); it.each([[4, "4"], [120, "99+"]])("retains unread value %i and the separate feedback survey", async (count, label) => { state.unread = count; await render(); - expect(items()[5].textContent).toContain(label); + expect(items()[4].textContent).toContain(label); expect(host.querySelector('a[aria-label="피드백 설문"]')).not.toBeNull(); expect(host.querySelector('a[href="/room-settings"]')).toBeNull(); }); it("hides the unread badge when no messages are unread", async () => { await render(); - expect(items()[5].querySelector("span.pointer-events-none")).toBeNull(); + expect(items()[4].querySelector("span.pointer-events-none")).toBeNull(); }); it("shows labeled feedback and bug report links in the sidebar", async () => { await render(); @@ -213,7 +213,7 @@ it("shows unread messages on the mobile chat tab", async () => { }); it.each([false, true])("keeps one selected destination while visiting all route tabs (mobile=%s)", async mobile => { state.mobile = mobile; - for (const [pathname, desktopIndex, mobileIndex] of [["/plan/room", 0, 0], ["/bookmark/folder", 2, 1], ["/cost", 3, 2]] as const) { + for (const [pathname, desktopIndex, mobileIndex] of [["/plan/room", 0, 0], ["/bookmark/folder", 1, 1], ["/cost", 2, 2]] as const) { state.pathname = pathname; await render(); expect(active()).toEqual([items()[mobile ? mobileIndex : desktopIndex]]); expect(host.querySelector("[data-chat]")).toBeNull(); @@ -259,23 +259,23 @@ it("cost follows bookmarks, selects alone and closes chat", async () => { it.each(["/plan/room", "/cost"])("colors every sidebar icon consistently on %s", async pathname => { state.pathname = pathname; await render(); - expect(items()[3].querySelector("img")).toBeNull(); + expect(items()[2].querySelector("img")).toBeNull(); for (const [index, item] of items().entries()) { const mask = item.querySelector('[style*="mask"]'); expect(mask).not.toBeNull(); - const selected = pathname === "/cost" ? index === 3 : index === 0; + const selected = pathname === "/cost" ? index === 2 : index === 0; expect(mask!.classList.contains("bg-primary-subtle")).toBe(selected); expect(mask!.classList.contains("bg-icon")).toBe(!selected); expect(mask!.classList.contains("size-6")).toBe(true); } - expect(items()[3].querySelector('[style*="mask"]')!.style.maskImage).toContain("/icons/sidebar/calculator.svg"); + expect(items()[2].querySelector('[style*="mask"]')!.style.maskImage).toContain("/icons/sidebar/calculator.svg"); }); it.each([false, true])("packing selects its desktop item or mobile travel tools tab (mobile=%s)", async mobile => { state.mobile = mobile; state.pathname = "/packing/12345678-1234-1234-1234-123456789abc"; await render(); - expect(items().map(x => x.getAttribute("aria-label") ?? x.textContent)).toEqual(mobile ? ["일정", "북마크", "여행 도구", "채팅"] : ["일정", "검색", "북마크", "가계부", "준비물", "채팅"]); + expect(items().map(x => x.getAttribute("aria-label") ?? x.textContent)).toEqual(mobile ? ["일정", "북마크", "여행 도구", "채팅"] : ["일정", "북마크", "가계부", "준비물", "채팅"]); expect(active().map(item => item.getAttribute("aria-label"))).toEqual([mobile ? "여행 도구" : "준비물"]); expect(host.querySelector(`a[href="/packing/${state.roomId}"]`)).not.toBeNull(); expect(host.querySelector("[data-map]:not([hidden])")).toBeNull(); @@ -283,7 +283,7 @@ it.each([false, true])("packing selects its desktop item or mobile travel tools expect(host.querySelector("[data-main-content-scroll]")?.className).not.toContain("overflow-y-auto"); if (!mobile) { await openChatFromSidebar(); - expect(active()).toEqual([items()[5]]); + expect(active()).toEqual([items()[4]]); // 준비물은 지도 없이 전체 폭이지만, 채팅(`/chat`)은 지도 옆에 연다 expect(host.querySelector("[data-map]:not([hidden])")).not.toBeNull(); await control("minimize"); @@ -306,7 +306,7 @@ it('keeps /chat until another route commits, then shows that route', async () => state.pathname=`/packing/${state.roomId}`; await render(); await openChatFromSidebar(); - await act(async()=>items()[2].click()); + await act(async()=>items()[1].click()); expect(state.pathname).toBe('/chat'); expect(active().map(item=>item.getAttribute('aria-label'))).toEqual(['채팅']); state.pathname='/bookmark'; @@ -318,7 +318,7 @@ it('links back to the original packing tab from /chat', async () => { state.pathname=`/packing/${state.roomId}`; await render(); await openChatFromSidebar(); - expect(items()[4].getAttribute('href')).toBe(`/packing/${state.roomId}`); + expect(items()[3].getAttribute('href')).toBe(`/packing/${state.roomId}`); state.pathname=`/packing/${state.roomId}`; await render(); expect(host.querySelector('[data-chat]')).toBeNull(); expect(active().map(item=>item.getAttribute('aria-label'))).toEqual(['준비물']); diff --git a/src/components/map/Map.search-rank.test.tsx b/src/components/map/Map.search-rank.test.tsx new file mode 100644 index 00000000..fd417427 --- /dev/null +++ b/src/components/map/Map.search-rank.test.tsx @@ -0,0 +1,443 @@ +// @vitest-environment jsdom +import { act, useEffect, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, expect, it, vi } from "vitest"; +import TripMap from "./Map"; +import { MobileSearchResultCarousel } from "./MobileSearchResultCarousel"; +import { useMapPlaceSearch } from "./useMapPlaceSearch"; +import { + SelectedPlaceProvider, + useSelectedPlace, +} from "@/contexts/SelectedPlaceContext"; +import { MapToolbarLayoutProvider } from "@/contexts/MapToolbarLayoutContext"; +import { PlaceDetailPanel } from "@/components/place/PlaceDetailPanel"; +import { useSessionStore } from "@/stores/session-store"; +import { useMapCenterStore } from "@/stores/map-center-store"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; +import { useMapPinsFocusStore } from "@/stores/map-pins-focus-store"; +import { useSearchMapPinsStore } from "@/stores/search-map-pins-store"; +import { registerQueryClient } from "@/lib/query-client"; +import { placeDetailQueryKey } from "@/lib/places/place-queries"; +import { placePhotoUrlQueryKey } from "@/lib/place-photo-query"; +import { + ROOMS_QUERY_KEY, + bookmarkCategoriesQueryKey, + roomAllBookmarksQueryKey, + roomDetailQueryKey, + roomSchedulesQueryKey, + sessionUserQueryKey, +} from "@/lib/query-keys"; +const boundary = vi.hoisted(() => ({ + mobile: false, + page: vi.fn(), + track: vi.fn(), + router: { push: vi.fn(), replace: vi.fn(), back: vi.fn() }, +})); +vi.mock("next/navigation", () => ({ + usePathname: () => "/bookmark", + useSearchParams: () => new URLSearchParams(), + useRouter: () => boundary.router, +})); +vi.mock("@/contexts/MobileViewContext", () => ({ + useMobileView: () => ({ isMobileDevice: boundary.mobile }), +})); +vi.mock("@/hooks/usePlacesSearch", () => ({ + fetchPlacesPageWithPhotos: boundary.page, +})); +vi.mock("@/lib/analytics/track", () => ({ + AnalyticsEvents: { search: "view_search_results", viewPlace: "view_place" }, + trackAnalyticsEvent: boundary.track, +})); +vi.mock("@vis.gl/react-google-maps", () => ({ + useMap: () => null, + useMapsLibrary: () => null, + Map: ({ + children, + onClick, + }: { + children: ReactNode; + onClick?: (event: { + detail: { placeId: string; latLng: { lat: number; lng: number } }; + stop: () => void; + }) => void; + }) => ( +
+ + {children} +
+ ), + AdvancedMarker: ({ + children, + onClick, + }: { + children: ReactNode; + onClick?: (event: { stop: () => void }) => void; + }) => ( + + ), +})); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +let root: Root | undefined, client: QueryClient, host: HTMLDivElement; +let controller!: ReturnType; +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; + client?.clear(); + host?.remove(); +}); +const items = Array.from({ length: 11 }, (_, index) => ({ + googlePlaceId: `result-${index + 1}`, + name: `결과${index + 1}`, + category: "카페", + rating: 4.3, + userRatingCount: 5, + location: { lat: 37 + index / 100, lng: 127 }, +})); +function prediction(id = "anchor") { + return { + placeId: id, + mainText: { text: "첫 추천" }, + text: { text: "첫 추천" }, + toPlace: () => ({ + fetchFields: async () => {}, + location: { lat: () => 37, lng: () => 127 }, + }), + } as unknown as google.maps.places.PlacePrediction; +} +async function render(mobile: boolean) { + boundary.mobile = mobile; + boundary.track.mockClear(); + boundary.page.mockReset(); + client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + registerQueryClient(client); + client.setQueryData(ROOMS_QUERY_KEY, { + rooms: [{ id: "room", destinations: [] }], + }); + client.setQueryData(roomDetailQueryKey("room"), { + id: "room", + destinations: [], + }); + client.setQueryData(roomSchedulesQueryKey("room"), []); + client.setQueryData(bookmarkCategoriesQueryKey("room"), []); + client.setQueryData(roomAllBookmarksQueryKey("room"), []); + client.setQueryData(sessionUserQueryKey, { id: 7 }); + for (const item of [...items, { ...items[0], googlePlaceId: "anchor" }]) { + client.setQueryData(placeDetailQueryKey(item.googlePlaceId), { + googlePlaceId: item.googlePlaceId, + name: item.name, + formattedAddress: "주소", + location: item.location, + primaryType: "cafe", + primaryTypeDisplayName: "카페", + rating: 4.3, + userRatingCount: 5, + phoneNumber: "", + websiteUri: "", + googleMapsUri: "", + googleMapsLinks: null, + regularOpeningHours: null, + reviewSummary: null, + reviews: [], + }); + client.setQueryData(placePhotoUrlQueryKey(item.googlePlaceId), ""); + } + useSessionStore.setState({ currentRoomId: "room", sessionReady: true }); + useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 } }); + usePlaceSearchStore.getState().clear(); + usePlaceSearchStore.getState().setScope("7:room"); + useSearchMapPinsStore.getState().clearPins(); + function View() { + const api = useMapPlaceSearch(mobile); + const { selectedPlace, setSelectedPlace } = useSelectedPlace(); + useEffect(() => { + controller = api; + }); + return ( + <> + {}}> + + + {mobile ? ( + + ) : selectedPlace ? ( + setSelectedPlace(null)} + /> + ) : null} + + ); + } + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + await act(async () => + root!.render( + + + + + , + ), + ); +} +function views() { + return boundary.track.mock.calls.filter(([event]) => event === "view_place"); +} +function expectPrivateFieldsAbsent(properties: Record) { + for (const field of [ + "query", + "sessionToken", + "token", + "googlePlaceId", + "place_id", + ]) + expect(properties).not.toHaveProperty(field); +} +it("web prepended absent anchor remains unranked while original results keep third/fourth/eleventh buckets", async () => { + await render(false); + boundary.page.mockResolvedValue({ items, nextPageToken: null }); + await act(async () => { + await controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: {}, + prediction: prediction(), + }); + }); + expect(usePlaceSearchStore.getState().items[0].googlePlaceId).toBe("anchor"); + expect(views().at(-1)![1].rank_bucket).toBeUndefined(); + for (const [number, rank] of [ + [3, "1_3"], + [4, "4_10"], + [11, "11_plus"], + ] as const) { + const marker = [ + ...host.querySelectorAll("[data-marker]"), + ].find((button) => button.textContent === `결과${number}`)!; + await act(async () => marker.click()); + expect(views().at(-1)![1]).toMatchObject({ + interaction_source: "search", + rank_bucket: rank, + }); + expectPrivateFieldsAbsent(views().at(-1)![1]); + } +}); +it("web first-place view waits for Text Search membership and keeps its original eleventh rank", async () => { + await render(false); + let finish!: (page: unknown) => void; + boundary.page.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + let pending!: Promise; + await act(async () => { + pending = controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: {}, + prediction: prediction("result-11"), + }); + }); + await vi.waitFor(() => expect(boundary.page).toHaveBeenCalled()); + expect(views()).toHaveLength(0); + await act(async () => { + finish({ items, nextPageToken: null }); + await pending; + }); + expect(views()).toHaveLength(1); + expect(views()[0][1]).toMatchObject({ + rank_bucket: "11_plus", + interaction_source: "search", + }); + expectPrivateFieldsAbsent(views()[0][1]); +}); +it("web map-center fallback auto-selection retains the first result bucket", async () => { + await render(false); + boundary.page.mockResolvedValue({ items, nextPageToken: null }); + await act(async () => { + await controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: {}, + }); + }); + expect(views()).toHaveLength(1); + expect(views()[0][1].rank_bucket).toBe("1_3"); +}); +it("mobile expanded result emits original fourth/eleventh rank while specific prediction stays unranked", async () => { + await render(true); + boundary.page.mockResolvedValue({ items, nextPageToken: null }); + await act(async () => { + await controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: {}, + }); + }); + expect(views()).toHaveLength(0); + await act(async () => + host + .querySelector('button[aria-label="결과4 상세 보기"]')! + .click(), + ); + expect(views().at(-1)![1]).toMatchObject({ + rank_bucket: "4_10", + interaction_source: "search", + }); + await act(async () => usePlaceSearchStore.getState().select("result-11")); + expect(views().at(-1)![1]).toMatchObject({ + rank_bucket: "11_plus", + interaction_source: "search", + }); + expectPrivateFieldsAbsent(views().at(-1)![1]); + await act(async () => { + await controller.confirm({ + query: "원문", + kind: "specific", + generation: 0, + session: {}, + prediction: prediction("result-11"), + }); + }); + await act(async () => + host + .querySelector( + 'button[aria-label="결과11 상세 보기"]', + )! + .click(), + ); + expect(views().at(-1)![1].rank_bucket).toBeUndefined(); + expect(boundary.page).toHaveBeenCalledTimes(1); +}); + +it("failed Text Search settles the already visible recommendation as one unranked view", async () => { + await render(false); + boundary.page.mockRejectedValue(new Error("검색 실패")); + await act(async () => { + await controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: {}, + prediction: prediction(), + }); + }); + expect(views()).toHaveLength(1); + expect(views()[0][1].rank_bucket).toBeUndefined(); + expectPrivateFieldsAbsent(views()[0][1]); +}); + +it("cancelling rank lookup keeps complete visible data and cannot add late rank/view events", async () => { + await render(false); + let finish!: (page: unknown) => void; + boundary.page.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + let pending!: Promise; + await act(async () => { + pending = controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: {}, + prediction: prediction(), + }); + }); + await vi.waitFor(() => expect(boundary.page).toHaveBeenCalled()); + expect(views()).toHaveLength(0); + await act(async () => controller.invalidate(false)); + expect(views()).toHaveLength(1); + expect(views()[0][1].rank_bucket).toBeUndefined(); + await act(async () => { + finish({ items, nextPageToken: null }); + await pending; + }); + expect(views()).toHaveLength(1); + expect(usePlaceSearchStore.getState().items).toEqual([]); +}); + +it("ordinary POI selection does not inherit a previous Text Search rank", async () => { + await render(false); + boundary.page.mockResolvedValue({ items, nextPageToken: null }); + await act(async () => { + await controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: {}, + }); + }); + const fourth = [ + ...host.querySelectorAll("[data-marker]"), + ].find((button) => button.textContent === "결과4")!; + await act(async () => fourth.click()); + expect(views().at(-1)![1].rank_bucket).toBe("4_10"); + await act(async () => + host.querySelector("[data-general-poi]")!.click(), + ); + expect(views().at(-1)![1].interaction_source).toBe("map"); + expect(views().at(-1)![1].rank_bucket).toBeUndefined(); + expectPrivateFieldsAbsent(views().at(-1)![1]); +}); + +it("discover ownership settles still-visible complete recommendation once without late rank/view", async () => { + await render(false); + let finish!: (page: unknown) => void; + boundary.page.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + let pending!: Promise; + await act(async () => { + pending = controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: {}, + prediction: prediction(), + }); + }); + await vi.waitFor(() => expect(boundary.page).toHaveBeenCalled()); + expect(views()).toHaveLength(0); + expect(host.textContent).toContain("결과1"); + await act(async () => useMapPinsFocusStore.getState().claimFocus("discover")); + expect(views()).toHaveLength(1); + expect(views()[0][1].rank_bucket).toBeUndefined(); + expectPrivateFieldsAbsent(views()[0][1]); + expect(host.textContent).toContain("결과1"); + await act(async () => { + finish({ items, nextPageToken: null }); + await pending; + }); + expect(views()).toHaveLength(1); + expect(usePlaceSearchStore.getState().items).toEqual([]); + expect( + boundary.track.mock.calls.filter( + ([event]) => event === "view_search_results", + ), + ).toHaveLength(0); +}); diff --git a/src/components/map/Map.search-selection.test.tsx b/src/components/map/Map.search-selection.test.tsx new file mode 100644 index 00000000..b8d842d9 --- /dev/null +++ b/src/components/map/Map.search-selection.test.tsx @@ -0,0 +1,297 @@ +// @vitest-environment jsdom +import { act, useEffect, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { expect, it, vi } from "vitest"; +import { MapToolbarLayoutProvider } from "@/contexts/MapToolbarLayoutContext"; +import TripMap from "./Map"; +import { MapPlaceSearch } from "./MapPlaceSearch"; +import { useMobileMapSearchStore } from "@/stores/mobile-map-search-store"; +import { placeDetailQueryKey } from "@/lib/places/place-queries"; +import { placePhotoUrlQueryKey } from "@/lib/place-photo-query"; +import { useMapPlaceSearch } from "./useMapPlaceSearch"; +import { + SelectedPlaceProvider, + useSelectedPlace, +} from "@/contexts/SelectedPlaceContext"; +import { useSessionStore } from "@/stores/session-store"; +import { useMapCenterStore } from "@/stores/map-center-store"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; +import { useSearchMapPinsStore } from "@/stores/search-map-pins-store"; +import { useMapPinsFocusStore } from "@/stores/map-pins-focus-store"; +import { + ROOMS_QUERY_KEY, + bookmarkCategoriesQueryKey, + roomAllBookmarksQueryKey, + roomDetailQueryKey, + roomSchedulesQueryKey, + sessionUserQueryKey, +} from "@/lib/query-keys"; +const boundary = vi.hoisted(() => ({ + mobile: false, + page: vi.fn(), + track: vi.fn(), + click: undefined as + | undefined + | ((event: { + detail: { placeId?: string; latLng?: { lat: number; lng: number } }; + stop: () => void; + }) => void), +})); +vi.mock("next/navigation", () => ({ + usePathname: () => "/bookmark", + useSearchParams: () => new URLSearchParams(), +})); +vi.mock("@/contexts/MobileViewContext", () => ({ + useMobileView: () => ({ isMobileDevice: boundary.mobile }), +})); +vi.mock("@/hooks/usePlacesSearch", () => ({ + fetchPlacesPageWithPhotos: boundary.page, +})); +vi.mock("@/lib/analytics/track", () => ({ + AnalyticsEvents: { search: "search" }, + trackAnalyticsEvent: boundary.track, +})); +vi.mock("@vis.gl/react-google-maps", () => ({ + useMap: () => null, + useMapsLibrary: () => null, + Map: function GoogleMapBoundary({ + onClick, + children, + }: { + onClick: typeof boundary.click; + children: ReactNode; + }) { + useEffect(() => { + boundary.click = onClick; + }, [onClick]); + return
{children}
; + }, + AdvancedMarker: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +it.each([false, true])( + "a general POI selection cancels late whole results/pins/analytics (anchor=%s)", + async (withAnchor) => { + boundary.mobile = false; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(ROOMS_QUERY_KEY, { + rooms: [{ id: "room", destinations: [] }], + }); + client.setQueryData(roomDetailQueryKey("room"), { + id: "room", + destinations: [], + }); + client.setQueryData(roomSchedulesQueryKey("room"), []); + client.setQueryData(bookmarkCategoriesQueryKey("room"), []); + client.setQueryData(roomAllBookmarksQueryKey("room"), []); + client.setQueryData(sessionUserQueryKey, { id: 7 }); + useSessionStore.setState({ currentRoomId: "room" }); + useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 } }); + usePlaceSearchStore.getState().clear(); + usePlaceSearchStore.getState().setScope("7:room"); + useSearchMapPinsStore.getState().clearPins(); + boundary.track.mockClear(); + let finish!: (value: unknown) => void; + boundary.page.mockReset().mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + let api!: ReturnType; + function View() { + const controller = useMapPlaceSearch(false); + const { selectedPlace } = useSelectedPlace(); + useEffect(() => { + api = controller; + }); + return ( + <> +
{selectedPlace?.googlePlaceId}
+ {}}> + + + + ); + } + const host = document.createElement("div"); + const root = createRoot(host); + try { + await act(async () => + root.render( + + + + + , + ), + ); + const prediction = withAnchor + ? ({ + placeId: "anchor", + mainText: { text: "첫 추천" }, + text: { text: "첫 추천" }, + toPlace: () => ({ + fetchFields: async () => {}, + location: { lat: () => 1, lng: () => 2 }, + }), + } as unknown as google.maps.places.PlacePrediction) + : undefined; + let pending!: Promise; + await act(async () => { + pending = api.confirm({ + query: "원래 검색어", + kind: "whole", + session: {}, + generation: 0, + prediction, + }); + }); + await vi.waitFor(() => expect(boundary.page).toHaveBeenCalled()); + expect(boundary.click).toBeDefined(); + await act(async () => + boundary.click!({ + detail: { placeId: "poi", latLng: { lat: 3, lng: 4 } }, + stop: vi.fn(), + }), + ); + expect(host.querySelector("[data-selected]")?.textContent).toBe("poi"); + await act(async () => { + finish({ + items: [ + { + googlePlaceId: "late", + name: "늦은 결과", + location: { lat: 5, lng: 6 }, + }, + ], + nextPageToken: null, + }); + await pending; + }); + expect(host.querySelector("[data-selected]")?.textContent).toBe("poi"); + expect(usePlaceSearchStore.getState().items).toEqual([]); + expect(useSearchMapPinsStore.getState().pins).toEqual([]); + expect(useMapPinsFocusStore.getState().focus).not.toBe("search"); + expect(boundary.track).not.toHaveBeenCalled(); + } finally { + await act(async () => root.unmount()); + client.clear(); + } + }, +); + +it.each([false, true])( + "background click retains completed mobile search pins and actual cards (expanded=%s)", + async (expanded) => { + boundary.mobile = true; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(ROOMS_QUERY_KEY, { + rooms: [{ id: "room", destinations: [] }], + }); + client.setQueryData(roomDetailQueryKey("room"), { + id: "room", + destinations: [], + }); + client.setQueryData(roomSchedulesQueryKey("room"), []); + client.setQueryData(bookmarkCategoriesQueryKey("room"), []); + client.setQueryData(roomAllBookmarksQueryKey("room"), []); + client.setQueryData(sessionUserQueryKey, { id: 7 }); + client.setQueryData(placeDetailQueryKey("result"), { + googlePlaceId: "result", + name: "검색 장소", + location: { lat: 37, lng: 127 }, + formattedAddress: "주소", + primaryTypeDisplayName: "카페", + rating: 4.2, + userRatingCount: 2, + regularOpeningHours: null, + phoneNumber: "", + websiteUri: "", + googleMapsLinks: null, + reviewSummary: null, + reviews: [], + }); + client.setQueryData(placePhotoUrlQueryKey("result"), ""); + useSessionStore.setState({ currentRoomId: "room" }); + useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 } }); + usePlaceSearchStore.getState().clear(); + usePlaceSearchStore.getState().setScope("7:room"); + useSearchMapPinsStore.getState().clearPins(); + useMobileMapSearchStore.setState({ + open: false, + textQuery: "", + textSearchNonce: 0, + }); + boundary.page + .mockReset() + .mockResolvedValue({ + items: [ + { + googlePlaceId: "result", + name: "검색 장소", + category: "카페", + rating: 4.2, + location: { lat: 37, lng: 127 }, + }, + ], + nextPageToken: null, + }); + const host = document.createElement("div"); + const root = createRoot(host); + try { + await act(async () => + root.render( + + + {}}> + + + + + , + ), + ); + await act(async () => useMobileMapSearchStore.getState().openSearch()); + const input = document.querySelector("dialog input")!; + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(input, "원래 검색어"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ), + ); + await act(async () => + usePlaceSearchStore.getState().setExpanded(expanded), + ); + expect(host.querySelector("h3")?.textContent).toBe("검색 장소"); + const pins = useSearchMapPinsStore.getState().pins; + const queryCalls = boundary.page.mock.calls.length; + await act(async () => boundary.click!({ detail: {}, stop: vi.fn() })); + expect(useSearchMapPinsStore.getState().pins).toEqual(pins); + expect(useMapPinsFocusStore.getState().focus).toBe("search"); + expect(host.querySelector("h3")?.textContent).toBe("검색 장소"); + expect(host.querySelector("[data-marker]")).not.toBeNull(); + expect(usePlaceSearchStore.getState()).toMatchObject({ + query: "원래 검색어", + selectedId: "result", + expanded, + }); + expect(boundary.page).toHaveBeenCalledTimes(queryCalls); + } finally { + await act(async () => root.unmount()); + client.clear(); + } + }, +); diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index c012f57c..caec2701 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -36,6 +36,8 @@ import { isPlanPath } from "@/lib/layout/mainChromeLayoutWidth"; import { isMobileMapPathname } from "@/lib/mobile-view"; import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius"; import { useMapCenterStore } from "@/stores/map-center-store"; +import { useSearchRecenterStore } from "@/stores/search-recenter-store"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; import { useSearchMapPinsStore } from "@/stores/search-map-pins-store"; import { useSessionStore } from "@/stores/session-store"; import { useMapPinsFocusStore } from "@/stores/map-pins-focus-store"; @@ -282,11 +284,21 @@ function DestinationPanController({ return null; } +/** A new ordinary map selection supersedes search without deleting its reusable results. */ +function releaseSearchForMapSelection() { + usePlaceSearchStore.getState().invalidate(); + useMapPinsFocusStore.getState().releaseSearchFocusIfActive(); + useSearchMapPinsStore.getState().clearPins(); + useSearchRecenterStore.getState().clearSearchSnapshot(); +} + function MapSearchResultPins() { const pins = useSearchMapPinsStore((s) => s.pins); const pinsFocus = useMapPinsFocusStore((s) => s.focus); const { selectedPlace, setSelectedPlace } = useSelectedPlace(); - const selectedPlaceId = selectedPlace?.googlePlaceId?.trim() ?? ""; + const { isMobileDevice } = useMobileView(); + const searchSelectedId = usePlaceSearchStore((s) => s.selectedId); + const selectedPlaceId = isMobileDevice && !selectedPlace ? searchSelectedId ?? "" : selectedPlace?.googlePlaceId?.trim() ?? ""; if (pinsFocus !== "search") return null; @@ -300,6 +312,14 @@ function MapSearchResultPins() { position={{ lat: pin.lat, lng: pin.lng }} onClick={(e) => { e.stop(); + const result = usePlaceSearchStore.getState().items.find((item) => item.googlePlaceId === pin.googlePlaceId); + if (result) { + usePlaceSearchStore.getState().select(result.googlePlaceId); + if (isMobileDevice) { setSelectedPlace(null); return; } + setSelectedPlace(result, { itinerarySource: "search", analyticsSource: "search", analyticsRankBucket: usePlaceSearchStore.getState().searchRankBuckets[result.googlePlaceId], preserveMapZoom: true }); + return; + } + releaseSearchForMapSelection(); setSelectedPlace( { name: pin.name, @@ -445,6 +465,7 @@ export default function TripMap({ return; } if (placeId.length > 0) { + releaseSearchForMapSelection(); ev.stop(); const latLng = ev.detail.latLng; setSelectedPlace( @@ -460,7 +481,8 @@ export default function TripMap({ ); return; } - // 빈 곳을 누르면 상세를 닫고, 웹 일정에서 고른 장소(강조·일차 경로만 보기)도 푼다 + // 빈 곳은 완료된 검색 핀·카드를 유지한다. 진행 중인 응답만 무효화하고 상세·일정 강조를 닫는다. + if (usePlaceSearchStore.getState().status === "pending") usePlaceSearchStore.getState().invalidate(); setSelectedPlace(null); clearPlanFocus(); }; @@ -501,6 +523,7 @@ export default function TripMap({ /> + {!routeView && isMobileDevice ? : null} {/* 웹 일정에서 고른 장소로 이동 — 모바일(일반 지도·경로 보기)은 쓰지 않는다 */} {routeView || isMobileDevice ? null : } s.items); + const selectedId = usePlaceSearchStore((s) => s.selectedId); + const focus = useMapPinsFocusStore((s) => s.focus); + const { selectedPlace } = useSelectedPlace(); + const item = items.find((value) => value.googlePlaceId === selectedId); + const lat = item?.location.lat; + const lng = item?.location.lng; + useEffect(() => { + if (map && focus === "search" && !selectedPlace && lat != null && lng != null) panToWithinWorld(map, { lat, lng }); + }, [map, focus, selectedPlace, selectedId, lat, lng]); + if (!item || focus !== "search" || selectedPlace) return null; + return event.stop()}>; +} diff --git a/src/components/map/MapDiscoverToolbar.tsx b/src/components/map/MapDiscoverToolbar.tsx index c32381b1..63154f51 100644 --- a/src/components/map/MapDiscoverToolbar.tsx +++ b/src/components/map/MapDiscoverToolbar.tsx @@ -52,7 +52,7 @@ export function MapDiscoverToolbar({
diff --git a/src/components/map/MapPlaceSearch.test.tsx b/src/components/map/MapPlaceSearch.test.tsx new file mode 100644 index 00000000..c6eff9be --- /dev/null +++ b/src/components/map/MapPlaceSearch.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import { MapPlaceSearch } from "./MapPlaceSearch"; +import { useMapCenterStore } from "@/stores/map-center-store"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; +import { useSessionStore } from "@/stores/session-store"; +const boundary = vi.hoisted(() => ({ + suggestions: vi.fn(), + page: vi.fn(), + select: vi.fn(), + fetchFields: vi.fn(), +})); +const library = {}; +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})); +vi.mock("@vis.gl/react-google-maps", () => ({ useMapsLibrary: () => library })); +vi.mock("@/hooks/useSessionUser", () => ({ + useSessionUser: () => ({ data: { id: 7 } }), +})); +vi.mock("@/contexts/SelectedPlaceContext", () => ({ + useSelectedPlace: () => ({ + selectedPlace: null, + setSelectedPlace: boundary.select, + }), +})); +vi.mock("@/hooks/usePlacesSearch", () => ({ + fetchPlacesPageWithPhotos: boundary.page, +})); +vi.mock("@/lib/placesAutocompleteSuggest", () => ({ + AUTOCOMPLETE_SUGGEST_DEBOUNCE_MS: 1, + newAutocompleteSessionToken: () => ({}), + circleLocationBias: (center: unknown, radius: number) => ({ center, radius }), + fetchPlacePredictionsForRequest: boundary.suggestions, +})); +vi.mock("@/lib/analytics/track", () => ({ + AnalyticsEvents: { search: "search" }, + trackAnalyticsEvent: vi.fn(), +})); +let cleanup: (() => Promise) | undefined; +afterEach(async () => { + await cleanup?.(); + vi.clearAllMocks(); +}); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +it("specific Details failure retry returns to that input with a fresh session and never Text Search", async () => { + const prediction = { + placeId: "chosen", + mainText: { text: "추천 장소" }, + secondaryText: { text: "주소" }, + text: { text: "추천 장소 주소" }, + toPlace: () => ({ + fetchFields: boundary.fetchFields, + location: { lat: () => 1, lng: () => 2 }, + }), + }; + boundary.fetchFields + .mockRejectedValueOnce(new Error("장소 정보 실패")) + .mockResolvedValue(undefined); + boundary.suggestions.mockResolvedValue([prediction]); + boundary.page.mockResolvedValue({ items: [], nextPageToken: null }); + 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()); + const input = host.querySelector("input")!; + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(input, "원래 검색어"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + const token = boundary.suggestions.mock.calls[0][0].sessionToken; + await act(async () => { + const buttons = host.querySelectorAll('[role="option"] button'); + (buttons[1] as HTMLButtonElement).click(); + }); + expect(usePlaceSearchStore.getState().status).toBe("error"); + const retry = [...host.querySelectorAll("button")].find( + (button) => button.textContent === "다시 시도", + )!; + await act(async () => retry.click()); + expect(boundary.page).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(input); + expect(input.value).toBe("원래 검색어"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + expect(boundary.suggestions.mock.calls.at(-1)![0].sessionToken).not.toBe( + token, + ); + await act(async () => { + ( + host.querySelectorAll('[role="option"] button')[1] as HTMLButtonElement + ).click(); + }); + expect(boundary.fetchFields).toHaveBeenCalledTimes(2); + expect(boundary.page).not.toHaveBeenCalled(); + expect(usePlaceSearchStore.getState().selectedId).toBe("chosen"); +}); diff --git a/src/components/map/MapPlaceSearch.tsx b/src/components/map/MapPlaceSearch.tsx new file mode 100644 index 00000000..a6159d88 --- /dev/null +++ b/src/components/map/MapPlaceSearch.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useRef } from "react"; +import { flushSync } from "react-dom"; +import { useMobileMapSearchStore } from "@/stores/mobile-map-search-store"; +import { useSearchParams } from "next/navigation"; +import { MapPlaceSearchInput } from "@/components/search/MapPlaceSearchInput"; +import { LoadingIndicator } from "@/components/loading/LoadingIndicator"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; +import type { SearchConfirmation } from "@/lib/places/search-session"; +import { useMapPlaceSearch } from "./useMapPlaceSearch"; +import { MobileMapSearch } from "./MobileMapSearch"; +import { MobileSearchResultCarousel } from "./MobileSearchResultCarousel"; +import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; +import { useMapPinsFocusStore } from "@/stores/map-pins-focus-store"; + +export function MapPlaceSearch({ mobile }: { mobile: boolean }) { + const { confirm, invalidate, scopeKey } = useMapPlaceSearch(mobile); + const initialQuery = useSearchParams().get("q")?.trim() ?? ""; + const status = usePlaceSearchStore((s) => s.status); + const error = usePlaceSearchStore((s) => s.error); + const fallback = usePlaceSearchStore((s) => s.fallback); + const items = usePlaceSearchStore((s) => s.items); + const inputRef = useRef(null); + const confirmationKind = usePlaceSearchStore((s) => s.confirmationKind); + const query = usePlaceSearchStore((s) => s.query); + const focus = useMapPinsFocusStore((s) => s.focus); + const { selectedPlace } = useSelectedPlace(); + const confirmation = (value: SearchConfirmation) => confirm(value); + const feedback = + status === "pending" ? ( + + ) : status === "error" ? ( +
+ {error} + +
+ ) : status === "done" && !items.length ? ( +

+ 검색 결과가 없어요. +

+ ) : status === "done" && fallback ? ( +

+ 현재 지도 주변의 검색 결과입니다. +

+ ) : null; + return ( + <> + {mobile ? ( + + ) : ( +
+ + {feedback ? ( +
+ {feedback} +
+ ) : null} +
+ )} + {mobile && feedback ? ( +
+ {feedback} +
+ ) : null} + {mobile && focus === "search" && !selectedPlace ? ( + + ) : null} + + ); +} diff --git a/src/components/map/MapWithDetailPanel.tsx b/src/components/map/MapWithDetailPanel.tsx index 22f52b4c..727d02e2 100644 --- a/src/components/map/MapWithDetailPanel.tsx +++ b/src/components/map/MapWithDetailPanel.tsx @@ -9,7 +9,7 @@ import { useMapDiscoverToolbarOffset } from "@/hooks/useMapDiscoverToolbarOffset import { cn } from "@/lib/utils"; import Map, { type MapRouteView } from "./Map"; -import { MobileMapSearch } from "./MobileMapSearch"; +import { MapPlaceSearch } from "./MapPlaceSearch"; export function MapWithDetailPanel({ mobileInline = false, @@ -84,7 +84,7 @@ export function MapWithDetailPanel({ )}
- {mobileInline && !routeView ? : null} + {!routeView ? : null} ); } diff --git a/src/components/map/MobileMapSearch.history.test.tsx b/src/components/map/MobileMapSearch.history.test.tsx new file mode 100644 index 00000000..eb79f584 --- /dev/null +++ b/src/components/map/MobileMapSearch.history.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import { act, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; +import { MapPlaceSearch } from "./MapPlaceSearch"; +import { + SelectedPlaceProvider, + useSelectedPlace, +} from "@/contexts/SelectedPlaceContext"; +import { addMapSearchHistory } from "@/lib/map-search-history"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; +import { useMobileMapSearchStore } from "@/stores/mobile-map-search-store"; +import { useSessionStore } from "@/stores/session-store"; +import { useMapPinsFocusStore } from "@/stores/map-pins-focus-store"; +const boundary = vi.hoisted(() => ({ detail: vi.fn() })); +const library = {}; +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})); +vi.mock("@vis.gl/react-google-maps", () => ({ useMapsLibrary: () => library })); +vi.mock("@/hooks/useSessionUser", () => ({ + useSessionUser: () => ({ data: { id: 7 } }), +})); +vi.mock("@/lib/places/place-queries", () => ({ + fetchPlaceDetail: boundary.detail, +})); +vi.mock("./MobileSearchResultCarousel", () => ({ + MobileSearchResultCarousel: () => { + const items = usePlaceSearchStore((s) => s.items); + return
{items[0]?.name}
; + }, +})); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +it("saved history selection closes an existing POI and exposes its new collapsed card/map selection", async () => { + useSessionStore.setState({ currentRoomId: "room" }); + usePlaceSearchStore.getState().clear(); + usePlaceSearchStore.getState().setScope("7:room"); + usePlaceSearchStore.setState({ searchRankBuckets: { saved: "11_plus" } }); + useMobileMapSearchStore.setState({ + open: true, + textQuery: "", + textSearchNonce: 0, + }); + localStorage.clear(); + addMapSearchHistory(7, { + type: "place", + googlePlaceId: "saved", + name: "저장 장소", + address: "주소", + }); + boundary.detail.mockResolvedValue({ + googlePlaceId: "saved", + name: "저장 장소", + formattedAddress: "주소", + primaryTypeDisplayName: "카페", + rating: 4.3, + userRatingCount: 3, + regularOpeningHours: null, + location: { lat: 37, lng: 127 }, + }); + function View() { + const { selectedPlace, setSelectedPlace } = useSelectedPlace(); + useEffect(() => { + setSelectedPlace({ + name: "기존 POI", + category: "", + rating: null, + googlePlaceId: "old", + location: { lat: 1, lng: 2 }, + }); + }, [setSelectedPlace]); + return ( + <> +
{selectedPlace?.name}
+ + + ); + } + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + await act(async () => + root.render( + + + , + ), + ); + expect(host.querySelector("[data-detail]")?.textContent).toBe("기존 POI"); + const saved = [...document.querySelectorAll("dialog button")].find( + (button) => button.textContent?.includes("저장 장소"), + )!; + await act(async () => (saved as HTMLButtonElement).click()); + expect(host.querySelector("[data-detail]")?.textContent).toBe(""); + expect(host.querySelector("[data-search-card]")?.textContent).toBe( + "저장 장소", + ); + expect(usePlaceSearchStore.getState()).toMatchObject({ + selectedId: "saved", + expanded: false, + items: [{ googlePlaceId: "saved", location: { lat: 37, lng: 127 } }], + }); + expect(usePlaceSearchStore.getState().searchRankBuckets).toEqual({}); + expect(useMapPinsFocusStore.getState().focus).toBe("search"); + expect(useMobileMapSearchStore.getState().open).toBe(false); + } finally { + await act(async () => root.unmount()); + host.remove(); + localStorage.clear(); + } +}); diff --git a/src/components/map/MobileMapSearch.tsx b/src/components/map/MobileMapSearch.tsx index e47e9f69..82b39c00 100644 --- a/src/components/map/MobileMapSearch.tsx +++ b/src/components/map/MobileMapSearch.tsx @@ -1,101 +1,126 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; - -import { MobilePlaceSearchScreen } from "@/components/search/MobilePlaceSearchScreen"; +import { useEffect, useRef } from "react"; import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; +import { MobilePlaceSearchScreen } from "@/components/search/MobilePlaceSearchScreen"; import type { MapSearchPlaceEntry } from "@/lib/map-search-history"; import { fetchPlaceDetail } from "@/lib/places/place-queries"; +import type { SearchConfirmation } from "@/lib/places/search-session"; +import { + placeSearchResultsToMapPins, + setActiveSearchMapPins, +} from "@/lib/active-search-map-pins"; import { useMobileMapSearchStore } from "@/stores/mobile-map-search-store"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; +import { useSearchRecenterStore } from "@/stores/search-recenter-store"; +import { useMapPinsFocusStore } from "@/stores/map-pins-focus-store"; -import { useMobileMapTextSearch } from "./useMobileMapTextSearch"; - -function reopenSearch() { - useMobileMapSearchStore.getState().openSearch(); -} - -/** - * 모바일 지도 전체 화면 검색 - * - 자동완성 결과를 누르면 지도가 그 장소로 이동하고 바텀 시트가 열린다. - * - 엔터로 확정하면 현재 지도 영역을 텍스트 검색해 결과 핀을 여러 개 띄운다. - * 닫혀도 언마운트하지 않아 바텀 시트에서 뒤로가기로 돌아오면 입력·결과가 그대로 남는다. - */ -export function MobileMapSearch() { +type Props = { + confirm: (confirmation: SearchConfirmation) => Promise; + invalidate: (clear: boolean) => void; + scopeKey: string; + initialQuery?: string; +}; +/** Existing mobile entry, with session-connected confirmation and cached result cards. */ +export function MobileMapSearch({ + confirm, + invalidate, + scopeKey, + initialQuery, +}: Props) { + const textSearchNonce = useMobileMapSearchStore((s) => s.textSearchNonce); + const textQuery = useMobileMapSearchStore((s) => s.textQuery); const open = useMobileMapSearchStore((s) => s.open); const closeSearch = useMobileMapSearchStore((s) => s.closeSearch); const setFocusInput = useMobileMapSearchStore((s) => s.setFocusInput); - const submitTextSearch = useMobileMapSearchStore((s) => s.submitTextSearch); - const textQuery = useMobileMapSearchStore((s) => s.textQuery); const { setSelectedPlace } = useSelectedPlace(); const inputRef = useRef(null); - - useMobileMapTextSearch(); - - // 지도 검색바에서 검색어를 지우면 입력창도 비운다 - const [resetKey, setResetKey] = useState(0); - const [previousTextQuery, setPreviousTextQuery] = useState(textQuery); - if (previousTextQuery !== textQuery) { - setPreviousTextQuery(textQuery); - if (!textQuery) setResetKey((k) => k + 1); - } - + useEffect(() => { + if (initialQuery) useMobileMapSearchStore.getState().openSearch(); + }, [initialQuery]); useEffect(() => { setFocusInput(() => inputRef.current?.focus()); return () => { setFocusInput(null); - // 지도 화면을 떠나면 닫아 둔다 — 다시 들어왔을 때 검색 화면이 떠 있지 않도록 closeSearch(); }; }, [closeSearch, setFocusInput]); - - async function handleSelectPlace( + async function handleConfirm(confirmation: SearchConfirmation) { + const success = await confirm(confirmation); + if (success) closeSearch(); + return success; + } + async function handleSelectHistory( entry: MapSearchPlaceEntry, ): Promise { + const generation = usePlaceSearchStore.getState().invalidate(); + // Saved-place selection owns the mobile result surface just like a new confirmation. + setSelectedPlace(null); + usePlaceSearchStore.setState({ + query: entry.name, + confirmationKind: "specific", + status: "pending", + error: null, + }); try { const detail = await fetchPlaceDetail(entry.googlePlaceId); - closeSearch(); - setSelectedPlace( - { - name: detail.name || entry.name, - category: detail.primaryTypeDisplayName, - rating: detail.rating, - userRatingCount: detail.userRatingCount, - googlePlaceId: entry.googlePlaceId, - location: detail.location, - address: detail.formattedAddress || entry.address, - }, - { itinerarySource: "search", analyticsSource: "search", onBack: reopenSearch }, - ); - return { - type: "place", + const state = usePlaceSearchStore.getState(); + if (state.generation !== generation || state.scope !== scopeKey) + return null; + const item = { googlePlaceId: entry.googlePlaceId, name: detail.name || entry.name, address: detail.formattedAddress || entry.address, + category: detail.primaryTypeDisplayName || detail.primaryType, + rating: detail.rating, + userRatingCount: detail.userRatingCount, + isOpen: detail.regularOpeningHours?.openNow ?? null, + location: detail.location, + }; + useMapPinsFocusStore.getState().claimFocus("search"); + usePlaceSearchStore.setState({ + items: [item], + searchRankBuckets: {}, + selectedId: item.googlePlaceId, + expanded: false, + status: "done", + error: null, + fallback: false, + termination: "not-requested", + query: item.name, + confirmationKind: "specific", + }); + useSearchRecenterStore.getState().clearSearchSnapshot(); + setActiveSearchMapPins(placeSearchResultsToMapPins([item])); + useMobileMapSearchStore.setState({ textQuery: item.name }); + closeSearch(); + return { + type: "place", + googlePlaceId: item.googlePlaceId, + name: item.name, + address: item.address, }; } catch { - toast.error("장소 정보를 불러오지 못했어요."); + if (usePlaceSearchStore.getState().generation === generation) + usePlaceSearchStore.setState({ + status: "error", + error: "장소 정보를 불러오지 못했어요.", + }); return null; } } - - /** 엔터 검색 — 지도에 결과 핀을 띄운다 */ - function handleSubmitQuery(query: string) { - closeSearch(); - // 열려 있던 장소 시트가 결과 핀을 가리지 않도록 닫는다 - setSelectedPlace(null); - submitTextSearch(query); - } - return ( ); } diff --git a/src/components/map/MobileSearchResultCarousel.test.tsx b/src/components/map/MobileSearchResultCarousel.test.tsx new file mode 100644 index 00000000..0269493c --- /dev/null +++ b/src/components/map/MobileSearchResultCarousel.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { MobileSearchResultCarousel } from "./MobileSearchResultCarousel"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; +const state = vi.hoisted(() => ({ detail: vi.fn(), send: vi.fn() })); +vi.mock("@/hooks/useChatActions", () => ({ + useChatActions: () => ({ canSend: true, sendPlaceMessage: state.send }), +})); +vi.mock("@/app/(main)/map/_components/MobileRoutePlaceDetail", () => ({ + MobileRoutePlaceDetail: (props: unknown) => { + state.detail(props); + return
상세
; + }, +})); +vi.mock("@/app/(main)/map/_components/MobileRouteCardCarousel", () => ({ + MobileRouteCardCarousel: ({ + cards, + }: { + cards: { key: string; node: React.ReactNode }[]; + }) => ( +
+ {cards.map((c) => ( +
{c.node}
+ ))} +
+ ), +})); +vi.mock("@/components/place/AddToBookmarkModal", () => ({ + AddToBookmarkModal: () =>
북마크 대화상자
, +})); +vi.mock("@/components/place/AddToScheduleModal", () => ({ + AddToScheduleModal: () =>
일정 대화상자
, +})); +let root: Root, host: HTMLDivElement; +beforeEach(async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + state.detail.mockReset(); + state.send.mockReset(); + usePlaceSearchStore.setState({ + items: [ + { + googlePlaceId: "one", + name: "카페", + category: "", + rating: null, + location: { lat: 1, lng: 2 }, + }, + { + googlePlaceId: "two", + name: "공원", + category: "공원", + rating: 4.5, + userRatingCount: 3, + location: { lat: 3, lng: 4 }, + }, + ], + selectedId: "one", + expanded: false, + }); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + await act(async () => root.render()); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); +}); +async function click(label: string) { + await act(async () => + host + .querySelector(`button[aria-label="${label}"]`)! + .click(), + ); +} +it("collapsed cards never mount full detail; absent rating/category have no fake values", () => { + expect(state.detail).not.toHaveBeenCalled(); + expect(host.textContent).not.toContain("-"); + expect(host.textContent).toContain("4.5"); +}); +it("independent bookmark/chat/itinerary buttons do not expand", async () => { + await click("카페 북마크에 추가"); + expect(host.textContent).toContain("북마크 대화상자"); + expect(usePlaceSearchStore.getState().expanded).toBe(false); + await click("카페 채팅으로 보내기"); + expect(state.send).toHaveBeenCalledWith( + expect.objectContaining({ googlePlaceId: "one" }), + ); + await click("카페 일정에 추가"); + expect(host.textContent).toContain("일정 대화상자"); + expect(state.detail).not.toHaveBeenCalled(); +}); +it("same card expands and only focused expanded card activates Details", async () => { + await click("카페 상세 보기"); + expect(usePlaceSearchStore.getState().expanded).toBe(true); + expect( + state.detail.mock.calls.filter(([props]) => props.active), + ).toHaveLength(1); + expect(host.querySelector('button[aria-label="상세 접기"]')).not.toBeNull(); +}); diff --git a/src/components/map/MobileSearchResultCarousel.tsx b/src/components/map/MobileSearchResultCarousel.tsx new file mode 100644 index 00000000..9bc167fc --- /dev/null +++ b/src/components/map/MobileSearchResultCarousel.tsx @@ -0,0 +1,204 @@ +"use client"; + +import type { SearchRankBucket } from "@/lib/analytics/context"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + CalendarAddIcon, + ChatIcon, + ReactionStarIcon, + SavedIcon, +} from "@/assets/icons"; +import { MobileRouteCardCarousel } from "@/app/(main)/map/_components/MobileRouteCardCarousel"; +import { MobileRouteExpandedShell } from "@/app/(main)/map/_components/MobileRoutePlaceCard"; +import { MobileRoutePlaceDetail } from "@/app/(main)/map/_components/MobileRoutePlaceDetail"; +import { AddToBookmarkModal } from "@/components/place/AddToBookmarkModal"; +import { AddToScheduleModal } from "@/components/place/AddToScheduleModal"; +import { useChatActions } from "@/hooks/useChatActions"; +import type { PlaceSearchResult } from "@/hooks/usePlacesSearch"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; +import { cn } from "@/lib/utils"; + +export function MobileSearchResultCarousel() { + const items = usePlaceSearchStore((s) => s.items); + const searchRankBuckets = usePlaceSearchStore((s) => s.searchRankBuckets); + const selectedId = usePlaceSearchStore((s) => s.selectedId); + const expanded = usePlaceSearchStore((s) => s.expanded); + const select = usePlaceSearchStore((s) => s.select); + const setExpanded = usePlaceSearchStore((s) => s.setExpanded); + if (!items.length) return null; + return ( +
+ { + const item = items[index]; + if (item) select(item.googlePlaceId); + }} + cards={items.map((item) => ({ + key: item.googlePlaceId, + node: ( + { + select(item.googlePlaceId); + setExpanded(true); + }} + onCollapse={() => setExpanded(false)} + /> + ), + }))} + /> +
+ ); +} + +function SearchPlaceCard({ + item, + analyticsRankBucket, + expanded, + active, + onExpand, + onCollapse, +}: { + item: PlaceSearchResult; + analyticsRankBucket?: SearchRankBucket; + expanded: boolean; + active: boolean; + onExpand: () => void; + onCollapse: () => void; +}) { + const [modal, setModal] = useState<"bookmark" | "schedule" | null>(null); + const { canSend, sendPlaceMessage } = useChatActions(); + const summary = ( +
+ {!expanded ? ( + +
+ {item.rating != null ? ( +
+ + + {item.rating.toFixed(1)} + + {item.userRatingCount != null ? ( + + ({item.userRatingCount.toLocaleString()}개) + + ) : null} +
+ ) : null} +
+ + +
+ + + ); + return ( + <> + {expanded ? ( + + {summary} + + + ) : ( +
+ {summary} +
+ )} + {modal === "bookmark" ? ( + setModal(null)} + /> + ) : null} + {modal === "schedule" ? ( + setModal(null)} + /> + ) : null} + + ); +} diff --git a/src/components/map/useMapPlaceSearch.test.tsx b/src/components/map/useMapPlaceSearch.test.tsx new file mode 100644 index 00000000..404d9a52 --- /dev/null +++ b/src/components/map/useMapPlaceSearch.test.tsx @@ -0,0 +1,255 @@ +// @vitest-environment jsdom +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { MapPlaceSearchInput } from "@/components/search/MapPlaceSearchInput"; +import { useMapPlaceSearch } from "./useMapPlaceSearch"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; +import { useMapCenterStore } from "@/stores/map-center-store"; +import { useSessionStore } from "@/stores/session-store"; +const network = vi.hoisted(() => ({ + page: vi.fn(), + detail: vi.fn(), + select: vi.fn(), + track: vi.fn(), + suggest: vi.fn(), + user: 7, +})); +vi.mock("@/hooks/usePlacesSearch", () => ({ + fetchPlacesPageWithPhotos: network.page, +})); +vi.mock("@/lib/places/place-queries", () => ({ + fetchPlaceDetail: network.detail, + placeDetailQueryKey: (id: string) => ["places", "detail", id], +})); +vi.mock("@/contexts/SelectedPlaceContext", () => ({ + useSelectedPlace: () => ({ setSelectedPlace: network.select }), +})); +vi.mock("@/hooks/useSessionUser", () => ({ + useSessionUser: () => ({ data: { id: network.user } }), +})); +vi.mock("@/lib/analytics/track", () => ({ + AnalyticsEvents: { search: "search" }, + trackAnalyticsEvent: network.track, +})); +const library = {}; +vi.mock("@vis.gl/react-google-maps", () => ({ useMapsLibrary: () => library })); +vi.mock("@/lib/placesAutocompleteSuggest", () => ({ + AUTOCOMPLETE_SUGGEST_DEBOUNCE_MS: 1, + newAutocompleteSessionToken: () => ({}), + circleLocationBias: (center: unknown, radius: number) => ({ center, radius }), + fetchPlacePredictionsForRequest: network.suggest, +})); +let root: Root, host: HTMLDivElement, api: ReturnType; +function Harness({ mobile = false }: { mobile?: boolean }) { + const controller = useMapPlaceSearch(mobile); + useEffect(() => { + api = controller; + }); + return ( + + ); +} +function confirmation(kind: "whole" | "specific" = "whole") { + const fetchFields = vi.fn().mockResolvedValue(undefined); + return { + query: "원래 검색어", + kind, + generation: 1, + session: {}, + prediction: { + placeId: "first", + mainText: { text: "추천" }, + secondaryText: { text: "주소" }, + toPlace: () => ({ + fetchFields, + location: { lat: () => 37, lng: () => 127 }, + }), + }, + } as unknown as Parameters[0]; +} +beforeEach(async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + network.user = 7; + network.suggest.mockReset().mockResolvedValue([confirmation().prediction]); + network.page.mockReset().mockResolvedValue({ + items: [ + { + googlePlaceId: "other", + name: "다른", + category: "카페", + rating: 4.2, + location: { lat: 38, lng: 128 }, + }, + ], + nextPageToken: null, + }); + network.detail.mockReset().mockResolvedValue({ + googlePlaceId: "first", + name: "추천", + formattedAddress: "주소", + primaryTypeDisplayName: "음식점", + rating: 4.5, + userRatingCount: 3, + location: { lat: 37, lng: 127 }, + }); + network.select.mockReset(); + network.track.mockReset(); + useSessionStore.setState({ currentRoomId: "room" }); + usePlaceSearchStore.getState().clear(); + useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 }, zoom: 12 }); + host = document.createElement("div"); + root = createRoot(host); + await act(async () => root.render()); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); +}); +it("web retains first prediction selection absent from text results, preserving result metadata", async () => { + await act(async () => { + await api.confirm(confirmation()); + }); + expect(network.select).toHaveBeenCalledWith( + expect.objectContaining({ + googlePlaceId: "first", + location: { lat: 37, lng: 127 }, + }), + expect.objectContaining({ analyticsSource: "search" }), + ); + expect( + usePlaceSearchStore.getState().items.map((x) => x.googlePlaceId), + ).toEqual(["first", "other"]); + expect(usePlaceSearchStore.getState().items[1].rating).toBe(4.2); +}); +it("mobile whole search keeps cards collapsed and never eagerly loads complete Details", async () => { + await act(async () => root.render()); + await act(async () => { + await api.confirm(confirmation()); + }); + expect(network.detail).not.toHaveBeenCalled(); + expect(network.select).toHaveBeenLastCalledWith(null); + expect(usePlaceSearchStore.getState().expanded).toBe(false); + expect( + usePlaceSearchStore.getState().items.map((x) => x.googlePlaceId), + ).toEqual(["other"]); +}); +it("mobile specific loads only chosen detail without Text Search", async () => { + await act(async () => root.render()); + await act(async () => { + await api.confirm(confirmation("specific")); + }); + expect(network.page).not.toHaveBeenCalled(); + expect(network.detail).toHaveBeenCalledExactlyOnceWith("first"); + expect(usePlaceSearchStore.getState().items[0].rating).toBe(4.5); + expect(usePlaceSearchStore.getState().expanded).toBe(false); +}); +it("editing input suppresses a late response and analytics", async () => { + let finish!: (v: unknown) => void; + network.page.mockReturnValue( + new Promise((r) => { + finish = r; + }), + ); + const pending = api.confirm(confirmation()); + await vi.waitFor(() => expect(network.page).toHaveBeenCalled()); + await act(async () => api.invalidate(false)); + await act(async () => { + finish({ items: [], nextPageToken: null }); + await pending; + }); + expect(usePlaceSearchStore.getState().status).not.toBe("pending"); + expect(network.track).not.toHaveBeenCalled(); +}); + +async function typeInput(value: string) { + await act(async () => { + const input = host.querySelector("input")!; + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +it("actual input editing during confirmation blocks late Text Search publication", async () => { + let finish!: (v: unknown) => void; + network.page.mockReturnValue( + new Promise((r) => { + finish = r; + }), + ); + await typeInput("입력 원문"); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + await act(async () => + host + .querySelector("input")! + .dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ), + ); + expect(network.page).toHaveBeenCalled(); + await typeInput("새 입력"); + await act(async () => { + finish({ items: [{ googlePlaceId: "late" }], nextPageToken: null }); + }); + expect( + usePlaceSearchStore + .getState() + .items.some((x) => x.googlePlaceId === "late"), + ).toBe(false); + expect(network.track).not.toHaveBeenCalled(); +}); +it.each(["room", "account"])( + "%s change discards late search and clears previous identity data", + async (change) => { + let finish!: (v: unknown) => void; + network.page.mockReturnValue( + new Promise((r) => { + finish = r; + }), + ); + const pending = api.confirm(confirmation()); + await vi.waitFor(() => expect(network.page).toHaveBeenCalled()); + await act(async () => { + if (change === "room") + useSessionStore.setState({ currentRoomId: "different" }); + else network.user = 8; + root.render(); + }); + await act(async () => { + finish({ items: [{ googlePlaceId: "late" }], nextPageToken: null }); + await pending; + }); + expect(usePlaceSearchStore.getState().items).toEqual([]); + expect(host.querySelector("input")?.value).toBe(""); + expect(network.track).not.toHaveBeenCalled(); + }, +); + +it("editing while web's first-place detail is pending clears its visible selection", async () => { + let finish!: (value: unknown) => void; + network.page.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const pending = api.confirm(confirmation()); + await vi.waitFor(() => expect(network.page).toHaveBeenCalled()); + expect(network.select).toHaveBeenLastCalledWith( + expect.objectContaining({ googlePlaceId: "first" }), + expect.any(Object), + ); + await act(async () => api.invalidate(false)); + expect(network.select).toHaveBeenLastCalledWith(null); + await act(async () => { + finish({ items: [], nextPageToken: null }); + await pending; + }); +}); diff --git a/src/components/map/useMapPlaceSearch.ts b/src/components/map/useMapPlaceSearch.ts new file mode 100644 index 00000000..61efa623 --- /dev/null +++ b/src/components/map/useMapPlaceSearch.ts @@ -0,0 +1,294 @@ +"use client"; + +import { useEffect, useEffectEvent, useRef } from "react"; +import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; +import { useSessionUser } from "@/hooks/useSessionUser"; +import { + fetchPlacesPageWithPhotos, + type PlaceSearchResult, +} from "@/hooks/usePlacesSearch"; +import { + clearActiveSearchMapPins, + placeSearchResultsToMapPins, + setActiveSearchMapPins, +} from "@/lib/active-search-map-pins"; +import { + bucketResultCount, + bucketSearchRank, + type SearchRankBucket, +} from "@/lib/analytics/context"; +import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; +import { getQueryClient } from "@/lib/query-client"; +import { + fetchPlaceDetail, + placeDetailQueryKey, +} from "@/lib/places/place-queries"; +import { PLACES_SEARCH_PAGE_SIZE } from "@/lib/places/placesSearchPageSize"; +import { + confirmPlaceSearch, + type PredictionCoordinates, + type SearchConfirmation, +} from "@/lib/places/search-session"; +import { useMapCenterStore } from "@/stores/map-center-store"; +import { + searchPinWriteStillValid, + useMapPinsFocusStore, +} from "@/stores/map-pins-focus-store"; +import { useMobileMapSearchStore } from "@/stores/mobile-map-search-store"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; +import { useSearchRecenterStore } from "@/stores/search-recenter-store"; +import { useSessionStore } from "@/stores/session-store"; + +export function useMapPlaceSearch(mobile: boolean) { + const roomId = useSessionStore((s) => s.currentRoomId); + const userId = useSessionUser().data?.id; + const scopeKey = `${userId ?? "anonymous"}:${roomId ?? "no-room"}`; + const { selectedPlace, setSelectedPlace, analyticsRankPending } = + useSelectedPlace(); + const pinsFocus = useMapPinsFocusStore((s) => s.focus); + const recenterId = useSearchRecenterStore((s) => s.searchRecenterRequestId); + const legacyQuery = useMobileMapSearchStore((s) => s.textQuery); + const legacyNonce = useMobileMapSearchStore((s) => s.textSearchNonce); + const seenLegacyNonce = useRef(legacyNonce); + const seenRecenter = useRef(recenterId); + + useEffect(() => { + if (usePlaceSearchStore.getState().scope !== scopeKey) { + usePlaceSearchStore.getState().setScope(scopeKey); + clearActiveSearchMapPins(); + useSearchRecenterStore.getState().clearSearchSnapshot(); + setSelectedPlace(null); + } + }, [scopeKey, setSelectedPlace]); + + function invalidate(clear: boolean) { + if (clear) { + usePlaceSearchStore.getState().clear(); + clearActiveSearchMapPins(); + useSearchRecenterStore.getState().clearSearchSnapshot(); + useMapPinsFocusStore.getState().releaseSearchFocusIfActive(); + setSelectedPlace(null); + } else { + const selectedId = usePlaceSearchStore.getState().selectedId; + // Keep complete visible data, but unmount an unfinished detail selection so its late + // request can populate the public cache without changing the edited search UI. + if ( + selectedId && + !getQueryClient()?.getQueryData(placeDetailQueryKey(selectedId)) + ) + setSelectedPlace(null); + else if (analyticsRankPending && selectedPlace) + setSelectedPlace(selectedPlace, { + itinerarySource: "search", + analyticsSource: "search", + skipMapRecenter: true, + }); + usePlaceSearchStore.getState().invalidate(); + } + } + + const settleOwnershipLoss = useEffectEvent(() => { + if (usePlaceSearchStore.getState().status === "pending") invalidate(false); + }); + useEffect(() => { + if (pinsFocus !== "search") settleOwnershipLoss(); + }, [pinsFocus]); + + async function confirm( + confirmation: SearchConfirmation, + mode: "text" | "map_recenter" = "text", + ): Promise { + const query = confirmation.query.trim(); + if (!query) return false; + const state = usePlaceSearchStore.getState(); + const generation = state.invalidate(); + const epoch = useMapPinsFocusStore.getState().claimFocus("search"); + const isCurrent = () => + usePlaceSearchStore.getState().generation === generation && + usePlaceSearchStore.getState().scope === scopeKey && + useSessionStore.getState().currentRoomId === roomId && + searchPinWriteStillValid(epoch); + const mapCenter = useMapCenterStore.getState().mapCenter; + usePlaceSearchStore.setState({ + query, + confirmationKind: confirmation.kind, + status: "pending", + error: null, + expanded: false, + termination: "not-requested", + }); + if (mobile) { + setSelectedPlace(null); + useMobileMapSearchStore.setState({ textQuery: query }); + } + const toAnchor = (anchor: PredictionCoordinates): PlaceSearchResult => ({ + googlePlaceId: anchor.googlePlaceId, + name: + confirmation.prediction?.mainText?.text ?? + confirmation.prediction?.text.text ?? + "장소", + address: confirmation.prediction?.secondaryText?.text ?? "", + category: "", + rating: null, + location: anchor.location, + }); + let anchoredSelection: PlaceSearchResult | null = null; + try { + const result = await confirmPlaceSearch({ + ...confirmation, + mapCenter, + isCurrent, + onAnchor: (anchor) => { + usePlaceSearchStore.setState({ + termination: "completed", + ...(!mobile ? { selectedId: anchor.googlePlaceId } : {}), + }); + if (!mobile) { + anchoredSelection = toAnchor(anchor); + setSelectedPlace(anchoredSelection, { + itinerarySource: "search", + analyticsSource: "search", + analyticsRankPending: confirmation.kind === "whole", + }); + } + }, + search: async (params) => { + useSearchRecenterStore.getState().setSearchSnapshot({ + center: { lat: params.latitude, lng: params.longitude }, + zoom: useMapCenterStore.getState().zoom, + radius: params.radius, + }); + const page = await fetchPlacesPageWithPhotos({ + ...params, + pageSize: PLACES_SEARCH_PAGE_SIZE, + pageToken: undefined, + }); + return page.items; + }, + }); + if (!result || !isCurrent()) return false; + const searchRankBuckets: Record = + confirmation.kind === "whole" + ? Object.fromEntries( + result.items.map((item, index) => [ + item.googlePlaceId, + bucketSearchRank(index), + ]), + ) + : {}; + let items = result.items; + if (confirmation.kind === "specific" && result.anchor) { + // Only the chosen mobile recommendation needs full summary information now. + if (mobile) { + const detail = await fetchPlaceDetail(result.anchor.googlePlaceId); + if (!isCurrent()) return false; + items = [ + { + googlePlaceId: detail.googlePlaceId, + name: detail.name, + address: detail.formattedAddress, + category: detail.primaryTypeDisplayName || detail.primaryType, + rating: detail.rating, + userRatingCount: detail.userRatingCount, + isOpen: detail.regularOpeningHours?.openNow ?? null, + location: detail.location, + }, + ]; + } else items = [toAnchor(result.anchor)]; + useSearchRecenterStore.getState().clearSearchSnapshot(); + } else if ( + !mobile && + result.anchor && + !items.some( + (item) => item.googlePlaceId === result.anchor!.googlePlaceId, + ) + ) { + items = [toAnchor(result.anchor), ...items]; + } + const selectedId = + !mobile && result.anchor + ? result.anchor.googlePlaceId + : (items[0]?.googlePlaceId ?? null); + usePlaceSearchStore.setState({ + items, + searchRankBuckets, + selectedId, + status: "done", + fallback: result.fallback, + termination: result.termination, + }); + setActiveSearchMapPins(placeSearchResultsToMapPins(items)); + if (!mobile) { + const selected = result.anchor + ? (items.find( + (item) => item.googlePlaceId === result.anchor!.googlePlaceId, + ) ?? toAnchor(result.anchor)) + : (items[0] ?? null); + setSelectedPlace(selected, { + itinerarySource: "search", + analyticsSource: "search", + analyticsRankBucket: selected + ? searchRankBuckets[selected.googlePlaceId] + : undefined, + ...(result.anchor ? { skipMapRecenter: true } : {}), + }); + } + if (confirmation.kind === "whole") { + trackAnalyticsEvent(AnalyticsEvents.search, { + result_count_bucket: bucketResultCount(result.items.length), + search_mode: mode, + ...(roomId ? { room_id: roomId } : {}), + }); + } + return true; + } catch (error) { + if (!isCurrent()) return false; + if (anchoredSelection) + setSelectedPlace(anchoredSelection, { + itinerarySource: "search", + analyticsSource: "search", + skipMapRecenter: true, + }); + usePlaceSearchStore.setState({ + status: "error", + termination: + usePlaceSearchStore.getState().termination === "completed" + ? "completed" + : confirmation.prediction + ? "failed" + : "not-requested", + error: + error instanceof Error + ? error.message + : "검색에 실패했어요. 다시 시도해주세요.", + }); + return false; + } + } + const recenter = useEffectEvent(() => { + const query = usePlaceSearchStore.getState().query; + if (query) + void confirm( + { query, kind: "whole", session: {}, generation: 0 }, + "map_recenter", + ); + }); + useEffect(() => { + if (seenRecenter.current !== recenterId) { + seenRecenter.current = recenterId; + recenter(); + } + }, [recenterId]); + const clearLegacy = useEffectEvent(() => { + if (!legacyQuery && mobile && usePlaceSearchStore.getState().query) + invalidate(true); + }); + useEffect(() => { + if (seenLegacyNonce.current !== legacyNonce) { + seenLegacyNonce.current = legacyNonce; + clearLegacy(); + } + }, [legacyNonce, legacyQuery]); + + return { confirm, invalidate, scopeKey }; +} diff --git a/src/components/place/PlaceDetailPanel.lifecycle.test.tsx b/src/components/place/PlaceDetailPanel.lifecycle.test.tsx index e7261e8b..7dd4abe2 100644 --- a/src/components/place/PlaceDetailPanel.lifecycle.test.tsx +++ b/src/components/place/PlaceDetailPanel.lifecycle.test.tsx @@ -13,6 +13,8 @@ const state = vi.hoisted(() => ({ track: vi.fn(), header: null as ComponentProps | null, source: "", + failure: false, + retry: vi.fn(), })); vi.mock("@/hooks/useAnalyticsRoomId", () => ({ useAnalyticsRoomId: () => state.roomId })); vi.mock("@/lib/analytics/track", () => ({ @@ -24,14 +26,14 @@ vi.mock("@/hooks/useChatActions", () => ({ useChatActions: () => ({ canSend: false }), })); vi.mock("./usePlaceDetailData", () => ({ - usePlaceDetailData: () => ({ isLoading: false }), + usePlaceDetailData: () => ({ isLoading: false, isError: state.failure, refetch: state.retry }), })); vi.mock("./HeroSection", () => ({ HeroImage: () => null, HeroSkeleton: () => null, })); vi.mock("./HomeTab", () => ({ HomeTab: () => null })); -vi.mock("./ReviewsTab", () => ({ ReviewsTab: () => null })); +vi.mock("./ReviewsTab", () => ({ ReviewsTab: () =>
})); vi.mock("./PlaceSummaryHeader", () => ({ PlaceSummaryHeader: (props: ComponentProps) => { state.header = props; @@ -103,3 +105,15 @@ it("keeps the current entry source for schedule addition and emits one view per } await act(async () => root.unmount()); }); + +it("full-detail failure shows retry without no-review content or a successful view", async () => { + state.failure = true; state.track.mockClear(); state.retry.mockClear(); + const host = document.createElement("div"); const root = createRoot(host); + await act(async () => root.render( {}} />)); + expect(host.querySelector("[data-reviews]")).toBeNull(); + expect(host.querySelector('[role="alert"]')).not.toBeNull(); + expect(state.track).not.toHaveBeenCalled(); + const retry = [...host.querySelectorAll("button")].find((button) => button.textContent === "다시 시도"); + await act(async () => retry!.click()); expect(state.retry).toHaveBeenCalledTimes(1); + await act(async () => root.unmount()); state.failure = false; +}); diff --git a/src/components/place/PlaceDetailPanel.tsx b/src/components/place/PlaceDetailPanel.tsx index e226cb7a..1699bebe 100644 --- a/src/components/place/PlaceDetailPanel.tsx +++ b/src/components/place/PlaceDetailPanel.tsx @@ -65,6 +65,7 @@ export function PlaceDetailPanel({ const { analyticsRankBucket, + analyticsRankPending, analyticsSource, itinerarySource: selectedItinerarySource, } = useSelectedPlace(); @@ -74,7 +75,7 @@ export function PlaceDetailPanel({ const itinerarySource = selectedItinerarySource ?? "search"; - const { data: detailData, isLoading: isDetailLoading } = + const { data: detailData, isLoading: isDetailLoading, isError: isDetailError, refetch } = usePlaceDetailData(googlePlaceId); const displayName = @@ -94,7 +95,7 @@ export function PlaceDetailPanel({ lastTrackedPlaceIdRef.current = null; return; } - if (isDetailLoading || lastTrackedPlaceIdRef.current === placeId) { + if (isDetailLoading || isDetailError || analyticsRankPending || lastTrackedPlaceIdRef.current === placeId) { return; } lastTrackedPlaceIdRef.current = placeId; @@ -106,11 +107,13 @@ export function PlaceDetailPanel({ }); }, [ analyticsRankBucket, + analyticsRankPending, analyticsSource, analyticsRoomId, displayCategory, googlePlaceId, isDetailLoading, + isDetailError, itinerarySource, ]); const displayRating = detailData?.rating ?? rating; @@ -190,7 +193,12 @@ export function PlaceDetailPanel({ ? () => setBookmarkModalOpen(true) : undefined; - const tabs = ( + const tabs = isDetailError && !detailData ? ( +
+

장소 정보를 불러오지 못했어요.

+ +
+ ) : ( ({ - queryKey: ["places", "detail-panel", id], - queryFn: async () => { - const detail = await fetchPlaceDetail(id); - return toPlaceDetailResult(detail); - }, + return useQuery({ + queryKey: placeDetailQueryKey(id), + queryFn: () => requestPlaceDetail(id), + select: toPlaceDetailResult, enabled: id.length > 0, ...placeDetailQueryDefaults, }); diff --git a/src/components/search/MapMobileSearchScreen.tsx b/src/components/search/MapMobileSearchScreen.tsx new file mode 100644 index 00000000..02c7f116 --- /dev/null +++ b/src/components/search/MapMobileSearchScreen.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect, useEffectEvent, useState, type RefObject } from "react"; +import { createPortal } from "react-dom"; +import { + ChevronLeftIcon, + CloseIcon, + LocationIcon, + SearchIcon, +} from "@/assets/icons"; +import { useSessionUser } from "@/hooks/useSessionUser"; +import { + addMapSearchHistory, + mapSearchHistoryEntryKey, + readMapSearchHistory, + removeMapSearchHistory, + type MapSearchHistoryEntry, + type MapSearchPlaceEntry, +} from "@/lib/map-search-history"; +import type { SearchConfirmation } from "@/lib/places/search-session"; +import { MapPlaceSearchInput } from "./MapPlaceSearchInput"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; +import { cn } from "@/lib/utils"; + +export type MapMobileSearchProps = { + open: boolean; + onClose: () => void; + inputRef: RefObject; + backLabel: string; + onConfirm: (confirmation: SearchConfirmation) => Promise; + onInvalidate: (clear: boolean) => void; + onSelectPlace: ( + entry: MapSearchPlaceEntry, + ) => Promise; + scopeKey: string; + initialQuery?: string; + resetKey?: number; +}; +/** Map-only confirmation path; itinerary input consumers keep the original screen contract. */ +export function MapMobileSearchScreen({ + open, + onClose, + inputRef, + backLabel, + onConfirm, + onInvalidate, + onSelectPlace, + scopeKey, + initialQuery = "", + resetKey = 0, +}: MapMobileSearchProps) { + const error = usePlaceSearchStore((state) => state.error); + const userId = useSessionUser().data?.id; + const [query, setQuery] = useState(initialQuery); + const [history, setHistory] = useState([]); + const [picking, setPicking] = useState(false); + const resetInput = useEffectEvent(() => { + setQuery(initialQuery); + }); + useEffect(() => { + resetInput(); + }, [scopeKey, resetKey]); + const refresh = useEffectEvent(() => { + setHistory(readMapSearchHistory(userId)); + }); + useEffect(() => { + if (open) refresh(); + }, [open, userId]); + async function confirm(confirmation: SearchConfirmation) { + const success = await onConfirm(confirmation); + if (success) { + const prediction = + confirmation.kind === "specific" ? confirmation.prediction : null; + setHistory( + addMapSearchHistory( + userId, + prediction + ? { + type: "place", + googlePlaceId: prediction.placeId, + name: prediction.mainText?.text ?? prediction.text.text, + address: prediction.secondaryText?.text ?? "", + } + : { type: "query", query: confirmation.query }, + ), + ); + } + return success; + } + function close() { + onInvalidate(false); + inputRef.current?.blur(); + onClose(); + } + async function selectHistory(entry: MapSearchHistoryEntry) { + if (picking) return; + setPicking(true); + try { + if (entry.type === "query") { + await confirm({ + query: entry.query, + kind: "whole", + session: {}, + generation: 0, + }); + } else { + const saved = await onSelectPlace(entry); + if (saved) setHistory(addMapSearchHistory(userId, saved)); + } + } finally { + setPicking(false); + } + } + if (typeof document === "undefined") return null; + return createPortal( + +
+ +
+ +
+
+ {error ? ( +
+ {error} + +
+ ) : null} + {!query.trim() ? ( +
+

+ 검색 기록 +

+
    + {history.map((entry) => { + const Icon = entry.type === "place" ? LocationIcon : SearchIcon; + const title = entry.type === "place" ? entry.name : entry.query; + return ( +
  • + + +
  • + ); + })} +
+
+ ) : null} +
, + document.body, + ); +} diff --git a/src/components/search/MapPlaceSearchInput.test.tsx b/src/components/search/MapPlaceSearchInput.test.tsx new file mode 100644 index 00000000..74b131df --- /dev/null +++ b/src/components/search/MapPlaceSearchInput.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { MapPlaceSearchInput } from "./MapPlaceSearchInput"; +const sdk = vi.hoisted(() => ({ fetch: vi.fn() })); +const library = {}; +vi.mock("@vis.gl/react-google-maps", () => ({ useMapsLibrary: () => library })); +vi.mock("@/lib/placesAutocompleteSuggest", () => ({ + AUTOCOMPLETE_SUGGEST_DEBOUNCE_MS: 250, + newAutocompleteSessionToken: () => ({}), + circleLocationBias: (center: unknown, radius: number) => ({ center, radius }), + fetchPlacePredictionsForRequest: sdk.fetch, +})); +let root: Root, host: HTMLDivElement; +const confirm = vi.fn(), + invalidate = vi.fn(); +const p = { + placeId: "first", + mainText: { text: "추천" }, + secondaryText: { text: "주소" }, + text: { text: "추천 주소" }, +}; +beforeEach(async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.useFakeTimers(); + sdk.fetch.mockReset().mockResolvedValue([p]); + confirm.mockReset().mockResolvedValue(true); + invalidate.mockReset(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + await act(async () => + root.render( + , + ), + ); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.useRealTimers(); +}); +async function type(value: string) { + await act(async () => { + const input = host.querySelector("input")!; + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +async function wait() { + await act(async () => vi.advanceTimersByTimeAsync(250)); +} +async function key(key: string) { + await act(async () => + host + .querySelector("input")! + .dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })), + ); +} +it("puts all results first; unselected Enter retains original query and first prediction", async () => { + await type("원래"); + await wait(); + expect(host.querySelector('[role="option"]')?.textContent).toContain( + "전체 결과", + ); + await key("Enter"); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ query: "원래", kind: "whole", prediction: p }), + ); +}); +it("arrow-selected recommendation Enter confirms only that prediction", async () => { + await type("원래"); + await wait(); + await key("ArrowDown"); + await key("ArrowDown"); + await key("Enter"); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ query: "원래", kind: "specific", prediction: p }), + ); +}); +it("rejects blank input and duplicate confirmations", async () => { + await type(" "); + await key("Enter"); + expect(confirm).not.toHaveBeenCalled(); + await type("원래"); + await wait(); + confirm.mockReturnValue(new Promise(() => {})); + await key("Enter"); + await key("Enter"); + expect(confirm).toHaveBeenCalledTimes(1); +}); +it("ignores suggestions returned after clear", async () => { + let finish!: (v: unknown[]) => void; + sdk.fetch.mockReturnValue( + new Promise((r) => { + finish = r; + }), + ); + await type("원래"); + await wait(); + await type(""); + await act(async () => finish([p])); + expect(host.textContent).not.toContain("추천"); +}); +it("ignores old suggestions after new input and rotates consumed token for subsequent typing", async () => { + await type("첫"); + await wait(); + const token = sdk.fetch.mock.calls[0][0].sessionToken; + await key("Enter"); + await type("다음"); + await wait(); + expect(sdk.fetch.mock.calls[1][0].sessionToken).not.toBe(token); + expect(invalidate).toHaveBeenCalled(); +}); diff --git a/src/components/search/MapPlaceSearchInput.tsx b/src/components/search/MapPlaceSearchInput.tsx new file mode 100644 index 00000000..51c3b501 --- /dev/null +++ b/src/components/search/MapPlaceSearchInput.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { + useEffect, + useEffectEvent, + useId, + useRef, + useState, + type RefObject, +} from "react"; +import { useMapsLibrary } from "@vis.gl/react-google-maps"; +import { CircleCancelIcon, LocationIcon, SearchIcon } from "@/assets/icons"; +import { + AUTOCOMPLETE_SUGGEST_DEBOUNCE_MS, + circleLocationBias, + fetchPlacePredictionsForRequest, + newAutocompleteSessionToken, +} from "@/lib/placesAutocompleteSuggest"; +import type { SearchConfirmation } from "@/lib/places/search-session"; +import { useMapCenterStore } from "@/stores/map-center-store"; +import { cn } from "@/lib/utils"; + +export type MapPlaceSearchInputProps = { + onConfirm: (confirmation: SearchConfirmation) => Promise; + onInvalidate: (clear: boolean) => void; + scopeKey: string; + inputRef?: RefObject; + mobile?: boolean; + initialQuery?: string; + active?: boolean; + autoConfirm?: boolean; + onQueryChange?: (query: string) => void; +}; +type Suggestions = { + query: string; + items: google.maps.places.PlacePrediction[]; + session: object; + status: "done" | "error"; +}; + +/** Shared web/mobile map search. Original input and prediction/token identity survive confirmation. */ +export function MapPlaceSearchInput({ + onConfirm, + onInvalidate, + scopeKey, + inputRef, + mobile = false, + initialQuery = "", + active: enabled = true, + autoConfirm = false, + onQueryChange, +}: MapPlaceSearchInputProps) { + const placesLib = useMapsLibrary("places"); + const mapCenter = useMapCenterStore((s) => s.mapCenter); + const [value, setValue] = useState(initialQuery); + const [suggestions, setSuggestions] = useState(null); + const [active, setActive] = useState(-1); + const [open, setOpen] = useState(Boolean(initialQuery)); + const autoQuery = useRef(autoConfirm ? initialQuery : null); + const [pending, setPending] = useState(false); + const generation = useRef(0); + const token = useRef(null); + const confirmGeneration = useRef(null); + const timer = useRef | null>(null); + const listId = useId(); + const ownRef = useRef(null); + const fieldRef = inputRef ?? ownRef; + const containerRef = useRef(null); + const currentScope = useRef(scopeKey); + + function cancel() { + generation.current += 1; + if (timer.current) clearTimeout(timer.current); + timer.current = null; + } + const invalidateScope = useEffectEvent(() => { + cancel(); + token.current = null; + confirmGeneration.current = null; + setValue(""); + setSuggestions(null); + setActive(-1); + setOpen(false); + setPending(false); + }); + useEffect(() => { + if (currentScope.current !== scopeKey) { + currentScope.current = scopeKey; + invalidateScope(); + } + return () => { + cancel(); + }; + }, [scopeKey]); + + function schedule(query: string) { + cancel(); + setSuggestions(null); + setActive(-1); + if (!query.trim() || !placesLib) return; + const requestGeneration = generation.current; + timer.current = setTimeout(() => { + token.current ??= newAutocompleteSessionToken(); + const requestToken = token.current; + void fetchPlacePredictionsForRequest({ + input: query.trim(), + language: "ko", + sessionToken: + requestToken as google.maps.places.AutocompleteSessionToken, + ...(mapCenter + ? { locationBias: circleLocationBias(mapCenter, 50_000) } + : {}), + }).then( + (items) => { + if (requestGeneration === generation.current) + setSuggestions({ + query, + items, + session: requestToken, + status: "done", + }); + }, + () => { + if (requestGeneration === generation.current) + setSuggestions({ + query, + items: [], + session: requestToken, + status: "error", + }); + }, + ); + }, AUTOCOMPLETE_SUGGEST_DEBOUNCE_MS); + } + const retryReady = useEffectEvent(() => { + if (value.trim() && open) schedule(value); + }); + useEffect(() => { + if (placesLib && enabled) retryReady(); + }, [placesLib, enabled]); + useEffect(() => { + if (!enabled) cancel(); + }, [enabled]); + + function update(query: string) { + autoQuery.current = null; + onInvalidate(!query.trim()); + setValue(query); + onQueryChange?.(query); + setOpen(Boolean(query.trim())); + confirmGeneration.current = null; + setPending(false); + if (!query.trim()) token.current = null; + schedule(query); + } + async function confirm(index: number) { + if (!value.trim() || confirmGeneration.current !== null) return; + const ready = + suggestions?.query === value && suggestions.status === "done" + ? suggestions + : null; + const prediction = index > 0 ? ready?.items[index - 1] : ready?.items[0]; + if (index > 0 && !prediction) return; + const requestSession = ready?.session ?? token.current ?? {}; + cancel(); + const requestGeneration = generation.current; + confirmGeneration.current = requestGeneration; + token.current = null; + setPending(true); + setOpen(false); + fieldRef.current?.blur(); + try { + await onConfirm({ + query: value.trim(), + kind: index > 0 ? "specific" : "whole", + prediction, + session: requestSession, + generation: requestGeneration, + }); + } finally { + if (generation.current === requestGeneration) { + setPending(false); + confirmGeneration.current = null; + } + } + } + const confirmInitial = useEffectEvent(() => { + if ( + autoQuery.current && + autoQuery.current === value && + suggestions?.query === value + ) { + autoQuery.current = null; + void confirm(-1); + } + }); + useEffect(() => { + confirmInitial(); + }, [suggestions]); + const rows = suggestions?.query === value ? suggestions.items : []; + const showDropdown = open && Boolean(value.trim()); + return ( +
+
{ + event.preventDefault(); + void confirm(active); + }} + > +
+ + = 0 ? `${listId}-${active}` : undefined + } + aria-autocomplete="list" + aria-busy={pending} + value={value} + onChange={(event) => update(event.target.value)} + onFocus={() => { + setOpen(Boolean(value.trim())); + if ( + !token.current && + confirmGeneration.current === null && + value.trim() + ) + schedule(value); + }} + onBlur={(event) => { + if ( + confirmGeneration.current !== null || + containerRef.current?.contains( + event.relatedTarget as Node | null, + ) + ) + return; + cancel(); + token.current = null; + onInvalidate(false); + setOpen(false); + }} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setOpen(true); + setActive((previous) => Math.min(rows.length, previous + 1)); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setActive((previous) => Math.max(0, previous - 1)); + } + if (event.key === "Enter") { + event.preventDefault(); + void confirm(active); + } + if (event.key === "Escape") { + cancel(); + token.current = null; + onInvalidate(false); + setOpen(false); + } + }} + placeholder="떠나고 싶은 지역을 입력해주세요" + autoComplete="off" + className="min-w-0 flex-1 bg-transparent text-body-m-regular text-text outline-none placeholder:text-text-subtle [&::-webkit-search-cancel-button]:hidden" + /> + {value ? ( + + ) : null} +
+
+ {showDropdown ? ( +
    + void confirm(0)} + /> + {rows.map((prediction, index) => ( + void confirm(index + 1)} + place + /> + ))} + {suggestions?.status === "error" ? ( +
  • + 검색 추천을 불러오지 못했어요. 다시 입력해주세요. +
  • + ) : null} +
+ ) : null} +
+ ); +} +function SuggestionRow({ + id, + title, + subtitle, + active, + onClick, + place = false, +}: { + id: string; + title: string; + subtitle?: string; + active: boolean; + onClick: () => void; + place?: boolean; +}) { + const Icon = place ? LocationIcon : SearchIcon; + return ( +
  • + +
  • + ); +} diff --git a/src/components/search/MobilePlaceSearchScreen.tsx b/src/components/search/MobilePlaceSearchScreen.tsx index 93cb365b..104528ee 100644 --- a/src/components/search/MobilePlaceSearchScreen.tsx +++ b/src/components/search/MobilePlaceSearchScreen.tsx @@ -39,6 +39,8 @@ import { import { cn } from "@/lib/utils"; import { useMapCenterStore } from "@/stores/map-center-store"; +import { MapMobileSearchScreen, type MapMobileSearchProps } from "./MapMobileSearchScreen"; + type SuggestState = | { status: "idle" } | { status: "loading"; items: MapSearchPlaceEntry[] } @@ -83,7 +85,14 @@ type MobilePlaceSearchScreenProps = Readonly<{ * 모바일 전체 화면 장소 검색 — 입력하는 동안 자동완성 결과를 보여주고, 입력이 비면 검색 기록을 보여준다. * 닫혀도 언마운트하지 않아 다시 열면 입력·결과가 그대로 남는다. */ -export function MobilePlaceSearchScreen({ +export function MobilePlaceSearchScreen(props: MobilePlaceSearchScreenProps & Partial>) { + if (props.onConfirm && props.onInvalidate && props.scopeKey) { + return ; + } + return ; +} + +function LegacyMobilePlaceSearchScreen({ open, onClose, inputRef, diff --git a/src/contexts/SelectedPlaceContext.tsx b/src/contexts/SelectedPlaceContext.tsx index 2a546ef0..a8fe01d8 100644 --- a/src/contexts/SelectedPlaceContext.tsx +++ b/src/contexts/SelectedPlaceContext.tsx @@ -28,6 +28,8 @@ export type SetSelectedPlaceOptions = { itinerarySource?: ItinerarySource; /** 검색 결과 선택 순위 버킷 — `view_place` 이벤트에서 사용 */ analyticsRankBucket?: SearchRankBucket; + /** The visible recommendation is waiting for Text Search membership/rank. */ + analyticsRankPending?: boolean; /** `view_place`의 `interaction_source` */ analyticsSource?: AnalyticsSource; /** @@ -46,6 +48,7 @@ type SelectedPlaceContextType = { placeSelectionCameraRef: MutableRefObject; itinerarySource: ItinerarySource | null; analyticsRankBucket: SearchRankBucket | null; + analyticsRankPending: boolean; analyticsSource: AnalyticsSource | null; onBack: (() => void) | null; }; @@ -56,6 +59,7 @@ const SelectedPlaceContext = createContext({ placeSelectionCameraRef: { current: "full" }, itinerarySource: null, analyticsRankBucket: null, + analyticsRankPending: false, analyticsSource: null, onBack: null, }); @@ -67,22 +71,41 @@ export function SelectedPlaceProvider({ children }: { children: ReactNode }) { const [entry, setEntry] = useState<{ itinerarySource: ItinerarySource | null; analyticsRankBucket: SearchRankBucket | null; + analyticsRankPending: boolean; analyticsSource: AnalyticsSource | null; onBack: (() => void) | null; - }>({ itinerarySource: null, analyticsRankBucket: null, analyticsSource: null, onBack: null }); + }>({ + itinerarySource: null, + analyticsRankBucket: null, + analyticsRankPending: false, + analyticsSource: null, + onBack: null, + }); const setSelectedPlace = useCallback( - (place: SearchResultCardProps | null, options?: SetSelectedPlaceOptions) => { + ( + place: SearchResultCardProps | null, + options?: SetSelectedPlaceOptions, + ) => { if (place === null) { placeSelectionCameraRef.current = "full"; - setEntry({ itinerarySource: null, analyticsRankBucket: null, analyticsSource: null, onBack: null }); + setEntry({ + itinerarySource: null, + analyticsRankBucket: null, + analyticsRankPending: false, + analyticsSource: null, + onBack: null, + }); setSelectedPlaceState(null); return; } setEntry({ - itinerarySource: options?.itinerarySource ?? options?.analyticsSource ?? null, + itinerarySource: + options?.itinerarySource ?? options?.analyticsSource ?? null, analyticsRankBucket: options?.analyticsRankBucket ?? null, - analyticsSource: options?.analyticsSource ?? options?.itinerarySource ?? null, + analyticsRankPending: options?.analyticsRankPending ?? false, + analyticsSource: + options?.analyticsSource ?? options?.itinerarySource ?? null, onBack: options?.onBack ?? null, }); if (options?.skipMapRecenter === true) { diff --git a/src/lib/places/place-detail-cache.test.tsx b/src/lib/places/place-detail-cache.test.tsx new file mode 100644 index 00000000..7176dfcc --- /dev/null +++ b/src/lib/places/place-detail-cache.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { act, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { expect, it, vi } from "vitest"; +import { fetchPlaceDetail, placeDetailQueryKey } from "./place-queries"; +import { terminatePredictionSession } from "./search-session"; +import { registerQueryClient } from "@/lib/query-client"; +import { usePlaceDetailData } from "@/components/place/usePlaceDetailData"; +import { requestPlaceDetail, type PlaceDetail } from "@/lib/api/places"; +vi.mock("@/lib/api/places", async (original) => ({ + ...(await original()), + requestPlaceDetail: vi.fn(), +})); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +it("SDK coordinates never seed full detail; later panels reuse full information without another request", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + registerQueryClient(client); + const detail: PlaceDetail = { + googlePlaceId: "one", + name: "카페", + formattedAddress: "주소", + location: { lat: 1, lng: 2 }, + primaryType: "cafe", + primaryTypeDisplayName: "카페", + rating: 4.5, + userRatingCount: 3, + phoneNumber: "전화", + websiteUri: "https://example.com", + googleMapsUri: "https://maps.google.com", + googleMapsLinks: { + placeUri: "https://maps.google.com/place", + reviewsUri: "https://maps.google.com/reviews", + directionsUri: null, + writeAReviewUri: null, + photosUri: null, + }, + regularOpeningHours: { openNow: true, weekdayDescriptions: ["월요일 9시"] }, + reviewSummary: "요약", + reviews: [ + { + rating: 5, + text: "리뷰", + authorDisplayName: "작성자", + publishTime: "2026-01-01", + relativePublishTimeDescription: "최근", + }, + ], + }; + vi.mocked(requestPlaceDetail).mockReset().mockResolvedValue(detail); + await terminatePredictionSession( + { + placeId: "one", + toPlace: () => ({ + fetchFields: async () => {}, + location: { lat: () => 1, lng: () => 2 }, + }), + } as unknown as google.maps.places.PlacePrediction, + {}, + ); + expect(client.getQueryData(placeDetailQueryKey("one"))).toBeUndefined(); + await fetchPlaceDetail("places/one"); + let view: ReturnType; + function Panel() { + const result = usePlaceDetailData("one"); + useEffect(() => { + view = result; + }); + return null; + } + const root = createRoot(document.createElement("div")); + await act(async () => + root.render( + + + , + ), + ); + expect(requestPlaceDetail).toHaveBeenCalledTimes(1); + expect(view!.data).toMatchObject({ + openNow: true, + phone: "전화", + weekdayDescriptions: ["월요일 9시"], + reviews: detail.reviews, + placeUri: detail.googleMapsLinks!.placeUri, + }); + await act(async () => root.unmount()); + client.clear(); +}); diff --git a/src/lib/places/place-queries.ts b/src/lib/places/place-queries.ts index 5adb0e2a..70fdebed 100644 --- a/src/lib/places/place-queries.ts +++ b/src/lib/places/place-queries.ts @@ -14,7 +14,8 @@ import { } from "@/lib/place-photo-query"; export const placeDetailQueryDefaults = { - staleTime: 60_000, + staleTime: Infinity, + refetchOnMount: false, refetchOnWindowFocus: false, } as const; @@ -23,9 +24,9 @@ export const placePreviewQueryDefaults = { refetchOnWindowFocus: false, } as const; -function placeDetailQueryKey(googlePlaceId: string) { +export function placeDetailQueryKey(googlePlaceId: string) { const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; - return ["places", "detail", id] as const; + return ["places", "detail", normalizeGooglePlaceResourceId(id)] as const; } export function placePreviewQueryKey(googlePlaceId: string) { diff --git a/src/lib/places/search-session.test.ts b/src/lib/places/search-session.test.ts new file mode 100644 index 00000000..3bb472b8 --- /dev/null +++ b/src/lib/places/search-session.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; +import { + confirmPlaceSearch, + terminatePredictionSession, +} from "./search-session"; + +function prediction(id = "first", location = { lat: 37, lng: 127 }) { + const fetchFields = vi.fn().mockResolvedValue(undefined); + const place = { + fetchFields, + location: { lat: () => location.lat, lng: () => location.lng }, + }; + return { + placeId: id, + mainText: { text: "추천 이름" }, + secondaryText: { text: "주소" }, + toPlace: vi.fn(() => place), + fetchFields, + }; +} +function setup(overrides = {}) { + const p = prediction(); + const search = vi.fn().mockResolvedValue([{ googlePlaceId: "other" }]); + const args = { + query: "원래 검색어", + kind: "whole" as const, + prediction: p as unknown as google.maps.places.PlacePrediction, + session: {}, + mapCenter: { lat: 1, lng: 2 }, + isCurrent: () => true, + search, + ...overrides, + }; + return { args, p, search }; +} +describe("session-connected search", () => { + it("actually terminates once with location only, returning partial coordinates", async () => { + const p = prediction(); + const session = {}; + const a = terminatePredictionSession( + p as unknown as google.maps.places.PlacePrediction, + session, + ); + const b = terminatePredictionSession( + p as unknown as google.maps.places.PlacePrediction, + session, + ); + expect(await a).toEqual({ + kind: "coordinates", + googlePlaceId: "first", + location: { lat: 37, lng: 127 }, + termination: "completed", + }); + await b; + expect(p.toPlace).toHaveBeenCalledTimes(1); + expect(p.fetchFields).toHaveBeenCalledExactlyOnceWith({ + fields: ["location"], + }); + }); + it("searches original query within 5km around FIRST prediction, preserves it absent from results", async () => { + const { args, p, search } = setup(); + const result = await confirmPlaceSearch(args); + expect(search).toHaveBeenCalledExactlyOnceWith({ + query: "원래 검색어", + latitude: 37, + longitude: 127, + radius: 5000, + }); + expect(result?.anchor?.googlePlaceId).toBe(p.placeId); + expect(result?.items).toEqual([{ googlePlaceId: "other" }]); + expect(result?.fallback).toBe(false); + }); + it("specific prediction makes zero Text Search calls", async () => { + const { args, search } = setup({ kind: "specific" }); + expect((await confirmPlaceSearch(args))?.anchor?.googlePlaceId).toBe( + "first", + ); + expect(search).not.toHaveBeenCalled(); + }); + it.each(["absent", "failed"])( + "falls back to map center for %s first prediction without trying another", + async (mode) => { + const { args, p, search } = setup( + mode === "absent" ? { prediction: undefined } : {}, + ); + if (mode === "failed") + p.fetchFields.mockRejectedValue(new Error("details failed")); + expect((await confirmPlaceSearch(args))?.fallback).toBe(true); + expect(search).toHaveBeenCalledExactlyOnceWith({ + query: "원래 검색어", + latitude: 1, + longitude: 2, + radius: 5000, + }); + }, + ); + it("rejects no-center fallback and blank input without outbound calls", async () => { + const { args, search } = setup({ prediction: undefined, mapCenter: null }); + await expect(confirmPlaceSearch(args)).rejects.toThrow("지도 위치"); + await expect(confirmPlaceSearch({ ...args, query: " " })).rejects.toThrow( + "검색어", + ); + expect(search).not.toHaveBeenCalled(); + }); + it("suppresses late Details and does not start search", async () => { + let finish!: () => void; + let current = true; + const { args, p, search } = setup({ isCurrent: () => current }); + p.fetchFields.mockImplementation( + () => + new Promise((r) => { + finish = r; + }), + ); + const pending = confirmPlaceSearch(args); + current = false; + finish(); + expect(await pending).toBeNull(); + expect(search).not.toHaveBeenCalled(); + }); + it("suppresses late Text Search", async () => { + let finish!: (items: unknown[]) => void; + let current = true; + const { args, search } = setup({ isCurrent: () => current }); + search.mockImplementation( + () => + new Promise((r) => { + finish = r; + }), + ); + const pending = confirmPlaceSearch(args); + await vi.waitFor(() => expect(search).toHaveBeenCalled()); + current = false; + finish([]); + expect(await pending).toBeNull(); + }); + it("invalid coordinates cannot become a complete detail or a completed termination", async () => { + const p = prediction("bad", { lat: NaN, lng: 2 }); + await expect( + terminatePredictionSession( + p as unknown as google.maps.places.PlacePrediction, + {}, + ), + ).rejects.toThrow("좌표"); + }); +}); + +it("a consumed session cannot confirm a different prediction identity", async () => { + const session = {}; + const first = prediction("first"); + const other = prediction("other"); + await terminatePredictionSession( + first as unknown as google.maps.places.PlacePrediction, + session, + ); + await expect( + terminatePredictionSession( + other as unknown as google.maps.places.PlacePrediction, + session, + ), + ).rejects.toThrow("이미"); + expect(other.toPlace).not.toHaveBeenCalled(); +}); diff --git a/src/lib/places/search-session.ts b/src/lib/places/search-session.ts new file mode 100644 index 00000000..d1e03eb8 --- /dev/null +++ b/src/lib/places/search-session.ts @@ -0,0 +1,110 @@ +/** Location-only SDK response. Never write this value to the full detail cache. */ +export type PredictionCoordinates = { + kind: "coordinates"; + googlePlaceId: string; + location: google.maps.LatLngLiteral; + termination: "completed"; +}; +export type SearchConfirmation = { + query: string; + kind: "whole" | "specific"; + prediction?: google.maps.places.PlacePrediction; + /** Identity of the token used by the actual prediction request. */ + session: object; + generation: number; +}; +const terminations = new WeakMap< + object, + { googlePlaceId: string; promise: Promise } +>(); + +/** A consumed session remains single-flight even if its terminating request fails. */ +export function terminatePredictionSession( + prediction: google.maps.places.PlacePrediction, + session: object, +): Promise { + const pending = terminations.get(session); + if (pending) + return pending.googlePlaceId === prediction.placeId + ? pending.promise + : Promise.reject( + new Error("이미 확인한 검색 세션입니다. 다시 검색해주세요."), + ); + const request = (async (): Promise => { + const place = prediction.toPlace(); + await place.fetchFields({ fields: ["location"] }); + const lat = place.location?.lat(); + const lng = place.location?.lng(); + if ( + lat == null || + lng == null || + !Number.isFinite(lat) || + !Number.isFinite(lng) || + Math.abs(lat) > 90 || + Math.abs(lng) > 180 + ) { + throw new Error("장소 좌표를 확인할 수 없어요."); + } + return { + kind: "coordinates", + googlePlaceId: prediction.placeId, + location: { lat, lng }, + termination: "completed", + }; + })(); + terminations.set(session, { + googlePlaceId: prediction.placeId, + promise: request, + }); + return request; +} + +export async function confirmPlaceSearch( + args: Omit & { + mapCenter: google.maps.LatLngLiteral | null; + isCurrent: () => boolean; + onAnchor?: (anchor: PredictionCoordinates) => void; + search: (params: { + query: string; + latitude: number; + longitude: number; + radius: number; + }) => Promise; + }, +): Promise<{ + anchor: PredictionCoordinates | null; + items: T[]; + fallback: boolean; + termination: "completed" | "failed" | "not-requested"; +} | null> { + const query = args.query.trim(); + if (!query) throw new Error("검색어를 입력해주세요."); + if (!args.isCurrent()) return null; + let anchor: PredictionCoordinates | null = null; + let termination: "completed" | "failed" | "not-requested" = "not-requested"; + if (args.prediction) { + try { + anchor = await terminatePredictionSession(args.prediction, args.session); + termination = "completed"; + } catch (error) { + termination = "failed"; + if (args.kind === "specific" && args.isCurrent()) throw error; + } + } + if (!args.isCurrent()) return null; + if (anchor) args.onAnchor?.(anchor); + if (args.kind === "specific") { + if (!anchor) throw new Error("장소 정보를 확인할 수 없어요."); + return { anchor, items: [], fallback: false, termination }; + } + const center = anchor?.location ?? args.mapCenter; + if (!center) throw new Error("지도 위치를 확인한 뒤 다시 검색해주세요."); + const items = await args.search({ + query, + latitude: center.lat, + longitude: center.lng, + radius: 5000, + }); + if (!args.isCurrent()) return null; + return { anchor, items, fallback: !anchor, termination }; +} diff --git a/src/stores/place-search-store.ts b/src/stores/place-search-store.ts new file mode 100644 index 00000000..f549957e --- /dev/null +++ b/src/stores/place-search-store.ts @@ -0,0 +1,62 @@ +import { create } from "zustand"; +import type { SearchRankBucket } from "@/lib/analytics/context"; +import type { PlaceSearchResult } from "@/hooks/usePlacesSearch"; + +type PlaceSearchState = { + scope: string; + generation: number; + query: string; + confirmationKind: "whole" | "specific" | null; + items: PlaceSearchResult[]; + /** Coarse original Text Search rank, independent of retained recommendation/display order. */ + searchRankBuckets: Readonly>; + selectedId: string | null; + expanded: boolean; + status: "idle" | "pending" | "done" | "error"; + error: string | null; + fallback: boolean; + termination: "completed" | "failed" | "not-requested"; + setScope: (scope: string) => void; + invalidate: () => number; + select: (id: string) => void; + setExpanded: (expanded: boolean) => void; + clear: () => void; +}; +const empty = { + query: "", + confirmationKind: null, + items: [], + searchRankBuckets: {}, + selectedId: null, + expanded: false, + status: "idle" as const, + error: null, + fallback: false, + termination: "not-requested" as const, +}; +export const usePlaceSearchStore = create((set, get) => ({ + ...empty, + scope: "", + generation: 0, + setScope: (scope) => { + if (get().scope !== scope) + set({ ...empty, scope, generation: get().generation + 1 }); + }, + invalidate: () => { + const generation = get().generation + 1; + set({ + generation, + status: get().items.length ? "done" : "idle", + error: null, + }); + return generation; + }, + select: (selectedId) => + set({ + selectedId, + generation: get().generation + 1, + status: get().items.length ? "done" : "idle", + }), + setExpanded: (expanded) => set({ expanded }), + clear: () => set({ ...empty, generation: get().generation + 1 }), +})); From 8b31f26628c55acd00b4e757eaa0a505b2dc53a5 Mon Sep 17 00:00:00 2001 From: minbros Date: Wed, 14 Oct 2026 17:33:57 +0900 Subject: [PATCH 02/10] =?UTF-8?q?fix(search):=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EA=B8=B0=EB=B0=98=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=20=EC=A0=84=EC=B2=B4=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 발급 토큰으로 자동완성과 전체 상세 조회 연결 - 캐시 및 종료 메타데이터를 구분하고 동일 상세 응답 재사용 - 유니코드와 nullable 정보, 중복·실패·지연 응답 회귀 검증 --- src/components/map/Map.search-rank.test.tsx | 34 ++- .../map/Map.search-selection.test.tsx | 21 +- .../MapPlaceSearch.server-session.test.tsx | 82 ++++++ src/components/map/MapPlaceSearch.test.tsx | 44 ++- src/components/map/MapPlaceSearch.tsx | 4 +- .../map/MobileMapSearch.history.test.tsx | 3 +- src/components/map/MobileMapSearch.tsx | 9 +- src/components/map/useMapPlaceSearch.test.tsx | 78 ++--- src/components/map/useMapPlaceSearch.ts | 69 +++-- src/components/place/HomeTab.tsx | 6 +- src/components/place/ReviewsTab.null.test.tsx | 14 + src/components/place/ReviewsTab.tsx | 4 +- src/components/place/types.ts | 21 +- .../place/usePlaceDetailData.test.ts | 18 +- src/components/place/usePlaceDetailData.ts | 5 +- .../search/MapMobileSearchScreen.tsx | 6 +- .../search/MapPlaceSearchInput.test.tsx | 95 ++++-- src/components/search/MapPlaceSearchInput.tsx | 109 +++---- src/lib/api/place-sessions.test.ts | 23 ++ src/lib/api/place-sessions.ts | 64 +++++ src/lib/api/places.ts | 45 ++- src/lib/places/autocomplete-session.ts | 29 ++ src/lib/places/place-detail-cache.test.tsx | 28 +- src/lib/places/place-queries.ts | 2 +- src/lib/places/search-session.test.ts | 272 +++++++++--------- src/lib/places/search-session.ts | 140 ++++----- src/stores/place-search-store.ts | 5 +- 27 files changed, 729 insertions(+), 501 deletions(-) create mode 100644 src/components/map/MapPlaceSearch.server-session.test.tsx create mode 100644 src/components/place/ReviewsTab.null.test.tsx create mode 100644 src/lib/api/place-sessions.test.ts create mode 100644 src/lib/api/place-sessions.ts create mode 100644 src/lib/places/autocomplete-session.ts diff --git a/src/components/map/Map.search-rank.test.tsx b/src/components/map/Map.search-rank.test.tsx index fd417427..0ab3f0e5 100644 --- a/src/components/map/Map.search-rank.test.tsx +++ b/src/components/map/Map.search-rank.test.tsx @@ -1,4 +1,6 @@ // @vitest-environment jsdom +import { apiFetch } from "@/lib/api/client"; +vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() })); import { act, useEffect, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -107,15 +109,7 @@ const items = Array.from({ length: 11 }, (_, index) => ({ location: { lat: 37 + index / 100, lng: 127 }, })); function prediction(id = "anchor") { - return { - placeId: id, - mainText: { text: "첫 추천" }, - text: { text: "첫 추천" }, - toPlace: () => ({ - fetchFields: async () => {}, - location: { lat: () => 37, lng: () => 127 }, - }), - } as unknown as google.maps.places.PlacePrediction; + return { placeId: id, text: { text: "첫 추천", matches: null }, structuredFormat: null }; } async function render(mobile: boolean) { boundary.mobile = mobile; @@ -125,6 +119,10 @@ async function render(mobile: boolean) { defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); registerQueryClient(client); + vi.mocked(apiFetch).mockReset().mockImplementation(async (_url, init) => { + const body = JSON.parse(init!.body as string); + return Response.json({ detail: client.getQueryData(placeDetailQueryKey(body.googlePlaceId)), source: "CACHE", cacheStatus: "HIT", googleDetailsRequested: false, termination: "NOT_TERMINATED" }); + }); client.setQueryData(ROOMS_QUERY_KEY, { rooms: [{ id: "room", destinations: [] }], }); @@ -217,7 +215,7 @@ it("web prepended absent anchor remains unranked while original results keep thi query: "원문", kind: "whole", generation: 0, - session: {}, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, prediction: prediction(), }); }); @@ -253,7 +251,7 @@ it("web first-place view waits for Text Search membership and keeps its original query: "원문", kind: "whole", generation: 0, - session: {}, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, prediction: prediction("result-11"), }); }); @@ -278,7 +276,7 @@ it("web map-center fallback auto-selection retains the first result bucket", asy query: "원문", kind: "whole", generation: 0, - session: {}, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, }); }); expect(views()).toHaveLength(1); @@ -292,7 +290,7 @@ it("mobile expanded result emits original fourth/eleventh rank while specific pr query: "원문", kind: "whole", generation: 0, - session: {}, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, }); }); expect(views()).toHaveLength(0); @@ -316,7 +314,7 @@ it("mobile expanded result emits original fourth/eleventh rank while specific pr query: "원문", kind: "specific", generation: 0, - session: {}, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, prediction: prediction("result-11"), }); }); @@ -339,7 +337,7 @@ it("failed Text Search settles the already visible recommendation as one unranke query: "원문", kind: "whole", generation: 0, - session: {}, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, prediction: prediction(), }); }); @@ -362,7 +360,7 @@ it("cancelling rank lookup keeps complete visible data and cannot add late rank/ query: "원문", kind: "whole", generation: 0, - session: {}, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, prediction: prediction(), }); }); @@ -387,7 +385,7 @@ it("ordinary POI selection does not inherit a previous Text Search rank", async query: "원문", kind: "whole", generation: 0, - session: {}, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, }); }); const fourth = [ @@ -417,7 +415,7 @@ it("discover ownership settles still-visible complete recommendation once withou query: "원문", kind: "whole", generation: 0, - session: {}, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, prediction: prediction(), }); }); diff --git a/src/components/map/Map.search-selection.test.tsx b/src/components/map/Map.search-selection.test.tsx index b8d842d9..3a3adacd 100644 --- a/src/components/map/Map.search-selection.test.tsx +++ b/src/components/map/Map.search-selection.test.tsx @@ -1,4 +1,7 @@ // @vitest-environment jsdom +import { apiFetch } from "@/lib/api/client"; +import { registerQueryClient } from "@/lib/query-client"; +vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() })); import { act, useEffect, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -79,6 +82,8 @@ it.each([false, true])( const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); + registerQueryClient(client); + vi.mocked(apiFetch).mockResolvedValue(Response.json({ detail: { googlePlaceId: "anchor", name: "첫 추천", formattedAddress: "주소", location: { lat: 1, lng: 2 }, primaryType: null, primaryTypeDisplayName: null, rating: null, userRatingCount: null, phoneNumber: null, websiteUri: null, googleMapsUri: null, googleMapsLinks: null, regularOpeningHours: null, reviews: [] }, source: "GOOGLE", cacheStatus: "MISS", googleDetailsRequested: true, termination: "SUCCEEDED" })); client.setQueryData(ROOMS_QUERY_KEY, { rooms: [{ id: "room", destinations: [] }], }); @@ -130,23 +135,13 @@ it.each([false, true])( , ), ); - const prediction = withAnchor - ? ({ - placeId: "anchor", - mainText: { text: "첫 추천" }, - text: { text: "첫 추천" }, - toPlace: () => ({ - fetchFields: async () => {}, - location: { lat: () => 1, lng: () => 2 }, - }), - } as unknown as google.maps.places.PlacePrediction) - : undefined; + const prediction = withAnchor ? { placeId: "anchor", text: null, structuredFormat: null } : undefined; let pending!: Promise; await act(async () => { pending = api.confirm({ query: "원래 검색어", kind: "whole", - session: {}, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, generation: 0, prediction, }); @@ -192,6 +187,8 @@ it.each([false, true])( const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); + registerQueryClient(client); + vi.mocked(apiFetch).mockResolvedValue(Response.json({ detail: { googlePlaceId: "anchor", name: "첫 추천", formattedAddress: "주소", location: { lat: 1, lng: 2 }, primaryType: null, primaryTypeDisplayName: null, rating: null, userRatingCount: null, phoneNumber: null, websiteUri: null, googleMapsUri: null, googleMapsLinks: null, regularOpeningHours: null, reviews: [] }, source: "GOOGLE", cacheStatus: "MISS", googleDetailsRequested: true, termination: "SUCCEEDED" })); client.setQueryData(ROOMS_QUERY_KEY, { rooms: [{ id: "room", destinations: [] }], }); diff --git a/src/components/map/MapPlaceSearch.server-session.test.tsx b/src/components/map/MapPlaceSearch.server-session.test.tsx new file mode 100644 index 00000000..ffebce14 --- /dev/null +++ b/src/components/map/MapPlaceSearch.server-session.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, expect, it, vi } from "vitest"; +import { MapPlaceSearchInput } from "@/components/search/MapPlaceSearchInput"; +import { useMapPlaceSearch } from "./useMapPlaceSearch"; +import { usePlaceSearchStore } from "@/stores/place-search-store"; +import { useMapCenterStore } from "@/stores/map-center-store"; +import { useSessionStore } from "@/stores/session-store"; +import { apiFetch } from "@/lib/api/client"; +import { placeDetailQueryKey } from "@/lib/places/place-queries"; +import { registerQueryClient } from "@/lib/query-client"; +import { usePlaceDetailData } from "@/components/place/usePlaceDetailData"; +import { HomeTab } from "@/components/place/HomeTab"; +import { ReviewsTab } from "@/components/place/ReviewsTab"; +const external = vi.hoisted(() => ({ select: vi.fn(), track: vi.fn() })); +vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() })); +vi.mock("@/contexts/SelectedPlaceContext", () => ({ useSelectedPlace: () => ({ setSelectedPlace: external.select }) })); +vi.mock("@/hooks/useSessionUser", () => ({ useSessionUser: () => ({ data: { id: 7 } }) })); +vi.mock("@/lib/analytics/track", () => ({ AnalyticsEvents: { search: "search" }, trackAnalyticsEvent: external.track })); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const detail = { + googlePlaceId: "chosen", name: "서버 카페", formattedAddress: "서버 주소", location: { lat: 37, lng: 127 }, + primaryType: "cafe", primaryTypeDisplayName: "카페", rating: 4.8, userRatingCount: 5, + phoneNumber: "02-123-4567", websiteUri: "https://example.com", googleMapsUri: null, + googleMapsLinks: { placeUri: "https://maps.google.com/place", directionsUri: null, writeAReviewUri: null, reviewsUri: "https://maps.google.com/reviews", photosUri: null }, + regularOpeningHours: { openNow: true, secondaryHoursType: null, specialDays: [], periods: [], weekdayDescriptions: ["월요일 9시"], nextOpenTime: null, nextCloseTime: null }, + reviews: [{ rating: 5, text: "서버 리뷰", authorDisplayName: "작성자", publishTime: "2026-01-01", relativePublishTimeDescription: "최근" }], +}; +let cleanup: (() => Promise) | undefined; +afterEach(async () => { await cleanup?.(); vi.useRealTimers(); }); +it.each(["MISS", "HIT"] as const)("%s selected card and panels reuse complete resolve DTO with exact HTTP counts", async cacheStatus => { + vi.useFakeTimers(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); registerQueryClient(client); + if (cacheStatus === "HIT") client.setQueryData(placeDetailQueryKey("chosen"), { ...detail, name: "이전 캐시" }); + useSessionStore.setState({ currentRoomId: "room" }); usePlaceSearchStore.getState().clear(); + useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 } }); external.track.mockClear(); + vi.mocked(apiFetch).mockReset().mockImplementation(async (url, init) => { + if (String(url).endsWith("/places/sessions")) return Response.json({ sessionToken: "server-uuid", expiresAt: Date.now()+1800000 }, { status: 201 }); + if (String(url).endsWith("/places/autocomplete")) { + expect(JSON.parse(init!.body as string)).toEqual({ sessionToken: "server-uuid", input: "원문", latitude: 1, longitude: 2 }); + return Response.json({ suggestions: [{ placePrediction: { placeId: "chosen", text: null, structuredFormat: { mainText: { text: "SDK 아님", matches: null }, secondaryText: null } } }] }); + } + if (String(url).endsWith("/places/resolve")) { + expect(JSON.parse(init!.body as string)).toEqual({ sessionToken: "server-uuid", googlePlaceId: "chosen" }); + return Response.json({ detail, cacheStatus, source: cacheStatus === "HIT" ? "CACHE" : "GOOGLE", googleDetailsRequested: cacheStatus === "MISS", termination: cacheStatus === "MISS" ? "SUCCEEDED" : "NOT_TERMINATED" }); + } + throw new Error(`Unexpected supplement HTTP: ${String(url)}`); + }); + function Panel({ id }: { id: string }) { + const { data } = usePlaceDetailData(id); + return data ? <>

    {data.name}

    : null; + } + function App() { + const controller = useMapPlaceSearch(true); + const state = usePlaceSearchStore(); + return <>
    {state.items[0]?.name}
    {state.expanded && state.selectedId ? : null}; + } + const host = document.createElement("div"); const root = createRoot(host); + cleanup = async () => { await act(async () => root.unmount()); client.clear(); }; + await act(async () => root.render()); + await act(async () => { + const input = host.querySelector("input")!; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "원문"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => vi.advanceTimersByTimeAsync(250)); + await act(async () => (host.querySelectorAll('[role="option"] button')[1] as HTMLButtonElement).click()); + expect(host.querySelector("[data-card]")?.textContent).toBe("서버 카페"); + expect(apiFetch).toHaveBeenCalledTimes(3); // issuance + autocomplete + resolve, no TextSearch or GET detail + const raw = client.getQueryData(placeDetailQueryKey("chosen")); expect(raw).toEqual(detail); + expect(raw).not.toHaveProperty("reviewSummary"); + await act(async () => usePlaceSearchStore.getState().setExpanded(true)); + expect(host.textContent).toContain("서버 리뷰"); expect(host.textContent).toContain("월요일 9시"); + expect(host.querySelector('a[href="tel:02-123-4567"]')).not.toBeNull(); + expect(host.querySelector('a[href="https://maps.google.com/place"]')).not.toBeNull(); + await act(async () => usePlaceSearchStore.getState().setExpanded(false)); + await act(async () => usePlaceSearchStore.getState().setExpanded(true)); + expect(apiFetch).toHaveBeenCalledTimes(3); expect(client.getQueryData(placeDetailQueryKey("chosen"))).toBe(raw); + expect(external.track).not.toHaveBeenCalled(); +}); diff --git a/src/components/map/MapPlaceSearch.test.tsx b/src/components/map/MapPlaceSearch.test.tsx index c6eff9be..554b203e 100644 --- a/src/components/map/MapPlaceSearch.test.tsx +++ b/src/components/map/MapPlaceSearch.test.tsx @@ -1,4 +1,6 @@ // @vitest-environment jsdom +import { apiFetch } from "@/lib/api/client"; +vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() })); import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, expect, it, vi } from "vitest"; @@ -29,12 +31,6 @@ vi.mock("@/contexts/SelectedPlaceContext", () => ({ vi.mock("@/hooks/usePlacesSearch", () => ({ fetchPlacesPageWithPhotos: boundary.page, })); -vi.mock("@/lib/placesAutocompleteSuggest", () => ({ - AUTOCOMPLETE_SUGGEST_DEBOUNCE_MS: 1, - newAutocompleteSessionToken: () => ({}), - circleLocationBias: (center: unknown, radius: number) => ({ center, radius }), - fetchPlacePredictionsForRequest: boundary.suggestions, -})); vi.mock("@/lib/analytics/track", () => ({ AnalyticsEvents: { search: "search" }, trackAnalyticsEvent: vi.fn(), @@ -46,20 +42,18 @@ afterEach(async () => { }); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); it("specific Details failure retry returns to that input with a fresh session and never Text Search", async () => { - const prediction = { - placeId: "chosen", - mainText: { text: "추천 장소" }, - secondaryText: { text: "주소" }, - text: { text: "추천 장소 주소" }, - toPlace: () => ({ - fetchFields: boundary.fetchFields, - location: { lat: () => 1, lng: () => 2 }, - }), - }; - boundary.fetchFields - .mockRejectedValueOnce(new Error("장소 정보 실패")) - .mockResolvedValue(undefined); - boundary.suggestions.mockResolvedValue([prediction]); + const prediction = { placeId: "chosen", text: { text: "추천 장소 주소", matches: null }, structuredFormat: { mainText: { text: "추천 장소", matches: null }, secondaryText: { text: "주소", matches: null } } }; + let tokens = 0, resolves = 0; + vi.mocked(apiFetch).mockReset().mockImplementation(async url => { + if (String(url).endsWith("/places/sessions")) return Response.json({ sessionToken: `token-${++tokens}`, expiresAt: Date.now()+60000 }, { status: 201 }); + if (String(url).endsWith("/places/autocomplete")) return Response.json({ suggestions: [{ placePrediction: prediction }] }); + if (String(url).endsWith("/places/resolve")) { + if (++resolves === 1) return Response.json({ code: "EXTERNAL_API_ERROR", message: "장소 정보 실패" }, { status: 502 }); + return Response.json({ detail: { googlePlaceId: "chosen", name: "추천 장소", formattedAddress: "주소", location: { lat: 1, lng: 2 }, primaryType: null, primaryTypeDisplayName: null, rating: null, userRatingCount: null, phoneNumber: null, websiteUri: null, googleMapsUri: null, googleMapsLinks: null, regularOpeningHours: null, reviews: [] }, source: "GOOGLE", cacheStatus: "MISS", googleDetailsRequested: true, termination: "SUCCEEDED" }); + } + throw new Error(`Unexpected HTTP ${String(url)}`); + }); + const autocompleteCalls = () => vi.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/places/autocomplete")); boundary.page.mockResolvedValue({ items: [], nextPageToken: null }); useSessionStore.setState({ currentRoomId: "room" }); useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 } }); @@ -81,9 +75,9 @@ it("specific Details failure retry returns to that input with a fresh session an input.dispatchEvent(new Event("input", { bubbles: true })); }); await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 280)); }); - const token = boundary.suggestions.mock.calls[0][0].sessionToken; + const token = JSON.parse(autocompleteCalls()[0][1]!.body as string).sessionToken; await act(async () => { const buttons = host.querySelectorAll('[role="option"] button'); (buttons[1] as HTMLButtonElement).click(); @@ -97,9 +91,9 @@ it("specific Details failure retry returns to that input with a fresh session an expect(document.activeElement).toBe(input); expect(input.value).toBe("원래 검색어"); await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 280)); }); - expect(boundary.suggestions.mock.calls.at(-1)![0].sessionToken).not.toBe( + expect(JSON.parse(autocompleteCalls().at(-1)![1]!.body as string).sessionToken).not.toBe( token, ); await act(async () => { @@ -107,7 +101,7 @@ it("specific Details failure retry returns to that input with a fresh session an host.querySelectorAll('[role="option"] button')[1] as HTMLButtonElement ).click(); }); - expect(boundary.fetchFields).toHaveBeenCalledTimes(2); + expect(resolves).toBe(2); expect(boundary.page).not.toHaveBeenCalled(); expect(usePlaceSearchStore.getState().selectedId).toBe("chosen"); }); diff --git a/src/components/map/MapPlaceSearch.tsx b/src/components/map/MapPlaceSearch.tsx index a6159d88..88820c57 100644 --- a/src/components/map/MapPlaceSearch.tsx +++ b/src/components/map/MapPlaceSearch.tsx @@ -41,7 +41,7 @@ export function MapPlaceSearch({ mobile }: { mobile: boolean }) { className="shrink-0 text-primary underline" onClick={() => { if (confirmationKind === "specific") { - // A consumed failed SDK session must be replaced by fresh predictions. + // A failed server session must be replaced by fresh predictions. // Return to the chosen-place input instead of changing its intent to whole search. if (mobile) { flushSync(() => @@ -51,7 +51,7 @@ export function MapPlaceSearch({ mobile }: { mobile: boolean }) { } else inputRef.current?.focus(); return; } - void confirm({ query, kind: "whole", session: {}, generation: 0 }); + void confirm({ query, kind: "whole", session: null, generation: 0 }); }} > 다시 시도 diff --git a/src/components/map/MobileMapSearch.history.test.tsx b/src/components/map/MobileMapSearch.history.test.tsx index eb79f584..1e3b31dd 100644 --- a/src/components/map/MobileMapSearch.history.test.tsx +++ b/src/components/map/MobileMapSearch.history.test.tsx @@ -21,7 +21,8 @@ vi.mock("@vis.gl/react-google-maps", () => ({ useMapsLibrary: () => library })); vi.mock("@/hooks/useSessionUser", () => ({ useSessionUser: () => ({ data: { id: 7 } }), })); -vi.mock("@/lib/places/place-queries", () => ({ +vi.mock("@/lib/places/place-queries", async original => ({ + ...(await original()), fetchPlaceDetail: boundary.detail, })); vi.mock("./MobileSearchResultCarousel", () => ({ diff --git a/src/components/map/MobileMapSearch.tsx b/src/components/map/MobileMapSearch.tsx index 82b39c00..f373ae32 100644 --- a/src/components/map/MobileMapSearch.tsx +++ b/src/components/map/MobileMapSearch.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef } from "react"; import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; import { MobilePlaceSearchScreen } from "@/components/search/MobilePlaceSearchScreen"; import type { MapSearchPlaceEntry } from "@/lib/map-search-history"; -import { fetchPlaceDetail } from "@/lib/places/place-queries"; +import { fetchPlaceDetail, normalizePreviewLocation } from "@/lib/places/place-queries"; import type { SearchConfirmation } from "@/lib/places/search-session"; import { placeSearchResultsToMapPins, @@ -67,15 +67,17 @@ export function MobileMapSearch({ const state = usePlaceSearchStore.getState(); if (state.generation !== generation || state.scope !== scopeKey) return null; + const location = normalizePreviewLocation(detail.location); + if (!location) throw new Error("장소 좌표를 확인할 수 없어요."); const item = { googlePlaceId: entry.googlePlaceId, name: detail.name || entry.name, address: detail.formattedAddress || entry.address, - category: detail.primaryTypeDisplayName || detail.primaryType, + category: detail.primaryTypeDisplayName || detail.primaryType || "", rating: detail.rating, userRatingCount: detail.userRatingCount, isOpen: detail.regularOpeningHours?.openNow ?? null, - location: detail.location, + location, }; useMapPinsFocusStore.getState().claimFocus("search"); usePlaceSearchStore.setState({ @@ -87,6 +89,7 @@ export function MobileMapSearch({ error: null, fallback: false, termination: "not-requested", + resolution: null, query: item.name, confirmationKind: "specific", }); diff --git a/src/components/map/useMapPlaceSearch.test.tsx b/src/components/map/useMapPlaceSearch.test.tsx index 404d9a52..a0e5cef1 100644 --- a/src/components/map/useMapPlaceSearch.test.tsx +++ b/src/components/map/useMapPlaceSearch.test.tsx @@ -6,6 +6,10 @@ import { MapPlaceSearchInput } from "@/components/search/MapPlaceSearchInput"; import { useMapPlaceSearch } from "./useMapPlaceSearch"; import { usePlaceSearchStore } from "@/stores/place-search-store"; import { useMapCenterStore } from "@/stores/map-center-store"; +import { apiFetch } from "@/lib/api/client"; +import { QueryClient } from "@tanstack/react-query"; +import { registerQueryClient } from "@/lib/query-client"; +import { placeDetailQueryKey } from "@/lib/places/place-queries"; import { useSessionStore } from "@/stores/session-store"; const network = vi.hoisted(() => ({ page: vi.fn(), @@ -18,10 +22,7 @@ const network = vi.hoisted(() => ({ vi.mock("@/hooks/usePlacesSearch", () => ({ fetchPlacesPageWithPhotos: network.page, })); -vi.mock("@/lib/places/place-queries", () => ({ - fetchPlaceDetail: network.detail, - placeDetailQueryKey: (id: string) => ["places", "detail", id], -})); +vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() })); vi.mock("@/contexts/SelectedPlaceContext", () => ({ useSelectedPlace: () => ({ setSelectedPlace: network.select }), })); @@ -32,14 +33,6 @@ vi.mock("@/lib/analytics/track", () => ({ AnalyticsEvents: { search: "search" }, trackAnalyticsEvent: network.track, })); -const library = {}; -vi.mock("@vis.gl/react-google-maps", () => ({ useMapsLibrary: () => library })); -vi.mock("@/lib/placesAutocompleteSuggest", () => ({ - AUTOCOMPLETE_SUGGEST_DEBOUNCE_MS: 1, - newAutocompleteSessionToken: () => ({}), - circleLocationBias: (center: unknown, radius: number) => ({ center, radius }), - fetchPlacePredictionsForRequest: network.suggest, -})); let root: Root, host: HTMLDivElement, api: ReturnType; function Harness({ mobile = false }: { mobile?: boolean }) { const controller = useMapPlaceSearch(mobile); @@ -55,27 +48,27 @@ function Harness({ mobile = false }: { mobile?: boolean }) { ); } function confirmation(kind: "whole" | "specific" = "whole") { - const fetchFields = vi.fn().mockResolvedValue(undefined); return { - query: "원래 검색어", - kind, - generation: 1, - session: {}, - prediction: { - placeId: "first", - mainText: { text: "추천" }, - secondaryText: { text: "주소" }, - toPlace: () => ({ - fetchFields, - location: { lat: () => 37, lng: () => 127 }, - }), - }, - } as unknown as Parameters[0]; + query: "원래 검색어", kind, generation: 1, + session: { sessionToken: "server-token", expiresAt: Date.now()+60000 }, + prediction: { placeId: "first", text: null, structuredFormat: { mainText: { text: "추천", matches: null }, secondaryText: { text: "주소", matches: null } } }, + }; } +let client: QueryClient; +let rawDetail: Record; + beforeEach(async () => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); network.user = 7; - network.suggest.mockReset().mockResolvedValue([confirmation().prediction]); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + registerQueryClient(client); + rawDetail = { googlePlaceId: "first", name: "추천", formattedAddress: "주소", primaryType: "restaurant", primaryTypeDisplayName: "음식점", rating: 4.5, userRatingCount: 3, location: { lat: 37, lng: 127 }, phoneNumber: null, websiteUri: null, googleMapsUri: null, googleMapsLinks: null, regularOpeningHours: null, reviews: [] }; + vi.mocked(apiFetch).mockReset().mockImplementation(async url => { + if (String(url).endsWith("/places/sessions")) return Response.json({ sessionToken: "input-token", expiresAt: Date.now()+60000 }, { status: 201 }); + if (String(url).endsWith("/places/autocomplete")) return Response.json({ suggestions: [{ placePrediction: confirmation().prediction }] }); + if (String(url).endsWith("/places/resolve")) return Response.json({ detail: rawDetail, source: "GOOGLE", cacheStatus: "MISS", googleDetailsRequested: true, termination: "SUCCEEDED" }); + throw new Error(`Unexpected HTTP ${String(url)}`); + }); network.page.mockReset().mockResolvedValue({ items: [ { @@ -126,7 +119,7 @@ it("web retains first prediction selection absent from text results, preserving ).toEqual(["first", "other"]); expect(usePlaceSearchStore.getState().items[1].rating).toBe(4.2); }); -it("mobile whole search keeps cards collapsed and never eagerly loads complete Details", async () => { +it("mobile whole search keeps cards collapsed and reuses resolved full detail without GET supplement", async () => { await act(async () => root.render()); await act(async () => { await api.confirm(confirmation()); @@ -138,13 +131,14 @@ it("mobile whole search keeps cards collapsed and never eagerly loads complete D usePlaceSearchStore.getState().items.map((x) => x.googlePlaceId), ).toEqual(["other"]); }); -it("mobile specific loads only chosen detail without Text Search", async () => { +it("mobile specific reuses chosen resolved detail without Text Search", async () => { await act(async () => root.render()); await act(async () => { await api.confirm(confirmation("specific")); }); expect(network.page).not.toHaveBeenCalled(); - expect(network.detail).toHaveBeenCalledExactlyOnceWith("first"); + expect(vi.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/places/resolve"))).toHaveLength(1); + expect(client.getQueryData(placeDetailQueryKey("first"))).toEqual(rawDetail); expect(usePlaceSearchStore.getState().items[0].rating).toBe(4.5); expect(usePlaceSearchStore.getState().expanded).toBe(false); }); @@ -185,7 +179,7 @@ it("actual input editing during confirmation blocks late Text Search publication ); await typeInput("입력 원문"); await act(async () => { - await new Promise((r) => setTimeout(r, 10)); + await new Promise((r) => setTimeout(r, 280)); }); await act(async () => host @@ -233,7 +227,7 @@ it.each(["room", "account"])( }, ); -it("editing while web's first-place detail is pending clears its visible selection", async () => { +it("editing while Text Search is pending retains already resolved complete selection", async () => { let finish!: (value: unknown) => void; network.page.mockReturnValue( new Promise((resolve) => { @@ -247,9 +241,25 @@ it("editing while web's first-place detail is pending clears its visible selecti expect.any(Object), ); await act(async () => api.invalidate(false)); - expect(network.select).toHaveBeenLastCalledWith(null); + expect(network.select).toHaveBeenLastCalledWith(expect.objectContaining({ googlePlaceId: "first" }), expect.any(Object)); await act(async () => { finish({ items: [], nextPageToken: null }); await pending; }); }); + +it("scope loss during resolve cannot seed cache or display stale detail", async () => { + let finish!: (r: Response) => void; + vi.mocked(apiFetch).mockReturnValue(new Promise(r => { finish = r; })); + const pending = api.confirm(confirmation("specific")); + await act(async () => { network.user = 8; root.render(); }); + await act(async () => { finish(Response.json({ detail: rawDetail, source: "GOOGLE", cacheStatus: "MISS", googleDetailsRequested: true, termination: "SUCCEEDED" })); await pending; }); + expect(client.getQueryData(placeDetailQueryKey("first"))).toBeUndefined(); + expect(usePlaceSearchStore.getState().items).toEqual([]); + expect(network.track).not.toHaveBeenCalled(); +}); +it("cache HIT never falsely marks termination completed", async () => { + vi.mocked(apiFetch).mockResolvedValue(Response.json({ detail: rawDetail, source: "CACHE", cacheStatus: "HIT", googleDetailsRequested: false, termination: "NOT_TERMINATED" })); + await act(async () => { await api.confirm(confirmation("specific")); }); + expect(usePlaceSearchStore.getState().termination).toBe("NOT_TERMINATED"); +}); diff --git a/src/components/map/useMapPlaceSearch.ts b/src/components/map/useMapPlaceSearch.ts index 61efa623..8d2d8f92 100644 --- a/src/components/map/useMapPlaceSearch.ts +++ b/src/components/map/useMapPlaceSearch.ts @@ -19,14 +19,11 @@ import { } from "@/lib/analytics/context"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; import { getQueryClient } from "@/lib/query-client"; -import { - fetchPlaceDetail, - placeDetailQueryKey, -} from "@/lib/places/place-queries"; +import { placeDetailQueryKey } from "@/lib/places/place-queries"; import { PLACES_SEARCH_PAGE_SIZE } from "@/lib/places/placesSearchPageSize"; import { confirmPlaceSearch, - type PredictionCoordinates, + type ResolvedSearchAnchor, type SearchConfirmation, } from "@/lib/places/search-session"; import { useMapCenterStore } from "@/stores/map-center-store"; @@ -116,20 +113,23 @@ export function useMapPlaceSearch(mobile: boolean) { error: null, expanded: false, termination: "not-requested", + resolution: null, }); if (mobile) { setSelectedPlace(null); useMobileMapSearchStore.setState({ textQuery: query }); } - const toAnchor = (anchor: PredictionCoordinates): PlaceSearchResult => ({ + const toAnchor = (anchor: ResolvedSearchAnchor): PlaceSearchResult => ({ googlePlaceId: anchor.googlePlaceId, name: - confirmation.prediction?.mainText?.text ?? - confirmation.prediction?.text.text ?? - "장소", - address: confirmation.prediction?.secondaryText?.text ?? "", - category: "", - rating: null, + anchor.detail.name ?? + confirmation.prediction?.structuredFormat?.mainText?.text ?? + confirmation.prediction?.text?.text ?? "", + address: anchor.detail.formattedAddress ?? "", + category: anchor.detail.primaryTypeDisplayName ?? anchor.detail.primaryType ?? "", + rating: anchor.detail.rating, + userRatingCount: anchor.detail.userRatingCount, + isOpen: anchor.detail.regularOpeningHours?.openNow ?? null, location: anchor.location, }); let anchoredSelection: PlaceSearchResult | null = null; @@ -138,9 +138,20 @@ export function useMapPlaceSearch(mobile: boolean) { ...confirmation, mapCenter, isCurrent, + onResolved: (resolved) => { + usePlaceSearchStore.setState({ + termination: resolved.termination, + resolution: { + source: resolved.source, + cacheStatus: resolved.cacheStatus, + googleDetailsRequested: resolved.googleDetailsRequested, + termination: resolved.termination, + }, + }); + }, onAnchor: (anchor) => { usePlaceSearchStore.setState({ - termination: "completed", + termination: anchor.termination, ...(!mobile ? { selectedId: anchor.googlePlaceId } : {}), }); if (!mobile) { @@ -178,23 +189,7 @@ export function useMapPlaceSearch(mobile: boolean) { : {}; let items = result.items; if (confirmation.kind === "specific" && result.anchor) { - // Only the chosen mobile recommendation needs full summary information now. - if (mobile) { - const detail = await fetchPlaceDetail(result.anchor.googlePlaceId); - if (!isCurrent()) return false; - items = [ - { - googlePlaceId: detail.googlePlaceId, - name: detail.name, - address: detail.formattedAddress, - category: detail.primaryTypeDisplayName || detail.primaryType, - rating: detail.rating, - userRatingCount: detail.userRatingCount, - isOpen: detail.regularOpeningHours?.openNow ?? null, - location: detail.location, - }, - ]; - } else items = [toAnchor(result.anchor)]; + items = [toAnchor(result.anchor)]; useSearchRecenterStore.getState().clearSearchSnapshot(); } else if ( !mobile && @@ -252,11 +247,13 @@ export function useMapPlaceSearch(mobile: boolean) { usePlaceSearchStore.setState({ status: "error", termination: - usePlaceSearchStore.getState().termination === "completed" - ? "completed" - : confirmation.prediction - ? "failed" - : "not-requested", + usePlaceSearchStore.getState().termination === "SUCCEEDED" + ? "SUCCEEDED" + : usePlaceSearchStore.getState().termination === "NOT_TERMINATED" + ? "NOT_TERMINATED" + : confirmation.prediction + ? "failed" + : "not-requested", error: error instanceof Error ? error.message @@ -269,7 +266,7 @@ export function useMapPlaceSearch(mobile: boolean) { const query = usePlaceSearchStore.getState().query; if (query) void confirm( - { query, kind: "whole", session: {}, generation: 0 }, + { query, kind: "whole", session: null, generation: 0 }, "map_recenter", ); }); diff --git a/src/components/place/HomeTab.tsx b/src/components/place/HomeTab.tsx index ad4b857c..6b89fba0 100644 --- a/src/components/place/HomeTab.tsx +++ b/src/components/place/HomeTab.tsx @@ -2,10 +2,10 @@ import { Clock, ExternalLink, Globe, MapPin, Phone } from "lucide-react"; type Props = { isOpen?: boolean | null; - address?: string; - phone?: string; + address?: string | null; + phone?: string | null; hours?: string; - website?: string; + website?: string | null; googleMapsUrl?: string; reviewSummary?: string | null; }; diff --git a/src/components/place/ReviewsTab.null.test.tsx b/src/components/place/ReviewsTab.null.test.tsx new file mode 100644 index 00000000..7e83aecf --- /dev/null +++ b/src/components/place/ReviewsTab.null.test.tsx @@ -0,0 +1,14 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "vitest"; +import { ReviewsTab } from "./ReviewsTab"; +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +it("nullable review author and rating render without invented author or stars", async () => { + const host = document.createElement("div"); const root = createRoot(host); + try { + await act(async () => root.render()); + expect(host.textContent).not.toContain("null"); + expect(host.querySelector('[aria-label*="별점"]')).toBeNull(); + } finally { await act(async () => root.unmount()); } +}); diff --git a/src/components/place/ReviewsTab.tsx b/src/components/place/ReviewsTab.tsx index 99dd6d48..a3081638 100644 --- a/src/components/place/ReviewsTab.tsx +++ b/src/components/place/ReviewsTab.tsx @@ -74,7 +74,7 @@ export function ReviewsTab({ {reviews.map((review, i) => (
    - {review.authorDisplayName.charAt(0)} + {review.authorDisplayName?.charAt(0)}
    @@ -85,7 +85,7 @@ export function ReviewsTab({ {review.relativePublishTimeDescription}
    - + {review.rating != null ? : null}

    {review.text}

    diff --git a/src/components/place/types.ts b/src/components/place/types.ts index e62edea0..182351f4 100644 --- a/src/components/place/types.ts +++ b/src/components/place/types.ts @@ -1,22 +1,17 @@ export const TABS = ["홈", "리뷰"] as const; export type Tab = (typeof TABS)[number]; -export type PlaceReview = { - rating: number; - text: string; - authorDisplayName: string; - publishTime: string; - relativePublishTimeDescription: string; -}; +export type { PlaceReview } from "@/lib/api/places"; +import type { PlaceReview } from "@/lib/api/places"; export type PlaceDetailResult = { - name: string; - primaryTypeDisplayName: string; + name: string | null; + primaryTypeDisplayName: string | null; rating: number | null; - formattedAddress: string; - location: { lat: number; lng: number }; - phone: string; - websiteUri: string; + formattedAddress: string | null; + location: { lat: number; lng: number } | undefined; + phone: string | null; + websiteUri: string | null; placeUri: string | null; reviewsUri: string | null; openNow: boolean | null; diff --git a/src/components/place/usePlaceDetailData.test.ts b/src/components/place/usePlaceDetailData.test.ts index 98fb5435..a677008f 100644 --- a/src/components/place/usePlaceDetailData.test.ts +++ b/src/components/place/usePlaceDetailData.test.ts @@ -1,15 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { PlaceDetail } from "@/lib/api/places"; import * as placeDetailDataModule from "./usePlaceDetailData"; import type { PlaceDetailResult } from "./types"; -vi.mock("@/lib/places/place-queries", () => ({ - fetchPlaceDetail: vi.fn(), - placeDetailQueryDefaults: {}, -})); - type PlaceDetailDataModule = typeof placeDetailDataModule & { toPlaceDetailResult: (detail: PlaceDetail) => PlaceDetailResult; }; @@ -49,3 +44,14 @@ describe("toPlaceDetailResult", () => { ); }); }); +it("nullable resolve fields remain absent without fabricated coordinates or summary", () => { + const result = placeDetailDataModule.toPlaceDetailResult({ + googlePlaceId: "null", name: null, formattedAddress: null, location: { lat: null, lng: 2 }, + primaryType: null, primaryTypeDisplayName: null, rating: null, userRatingCount: null, + phoneNumber: null, websiteUri: null, googleMapsUri: null, googleMapsLinks: null, + regularOpeningHours: null, reviews: [{ rating: null, text: null, authorDisplayName: null, publishTime: null, relativePublishTimeDescription: null }], + }); + expect(result.location).toBeUndefined(); + expect(result.reviewSummary).toBeNull(); + expect(result.reviews[0].rating).toBeNull(); +}); diff --git a/src/components/place/usePlaceDetailData.ts b/src/components/place/usePlaceDetailData.ts index f53ac388..225e369f 100644 --- a/src/components/place/usePlaceDetailData.ts +++ b/src/components/place/usePlaceDetailData.ts @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { requestPlaceDetail, type PlaceDetail } from "@/lib/api/places"; import { placeDetailQueryKey, + normalizePreviewLocation, placeDetailQueryDefaults, } from "@/lib/places/place-queries"; import type { PlaceDetailResult } from "./types"; @@ -13,7 +14,7 @@ export function toPlaceDetailResult(detail: PlaceDetail): PlaceDetailResult { primaryTypeDisplayName: detail.primaryTypeDisplayName, rating: detail.rating, formattedAddress: detail.formattedAddress, - location: detail.location, + location: normalizePreviewLocation(detail.location), phone: detail.phoneNumber, websiteUri: detail.websiteUri, placeUri: detail.googleMapsLinks?.placeUri ?? null, @@ -21,7 +22,7 @@ export function toPlaceDetailResult(detail: PlaceDetail): PlaceDetailResult { openNow: detail.regularOpeningHours?.openNow ?? null, weekdayDescriptions: detail.regularOpeningHours?.weekdayDescriptions ?? [], userRatingCount: detail.userRatingCount, - reviewSummary: detail.reviewSummary, + reviewSummary: detail.reviewSummary ?? null, reviews: detail.reviews ?? [], }; } diff --git a/src/components/search/MapMobileSearchScreen.tsx b/src/components/search/MapMobileSearchScreen.tsx index 02c7f116..f04a8367 100644 --- a/src/components/search/MapMobileSearchScreen.tsx +++ b/src/components/search/MapMobileSearchScreen.tsx @@ -78,8 +78,8 @@ export function MapMobileSearchScreen({ ? { type: "place", googlePlaceId: prediction.placeId, - name: prediction.mainText?.text ?? prediction.text.text, - address: prediction.secondaryText?.text ?? "", + name: prediction.structuredFormat?.mainText?.text ?? prediction.text?.text ?? "", + address: prediction.structuredFormat?.secondaryText?.text ?? "", } : { type: "query", query: confirmation.query }, ), @@ -100,7 +100,7 @@ export function MapMobileSearchScreen({ await confirm({ query: entry.query, kind: "whole", - session: {}, + session: null, generation: 0, }); } else { diff --git a/src/components/search/MapPlaceSearchInput.test.tsx b/src/components/search/MapPlaceSearchInput.test.tsx index 74b131df..e170d896 100644 --- a/src/components/search/MapPlaceSearchInput.test.tsx +++ b/src/components/search/MapPlaceSearchInput.test.tsx @@ -3,28 +3,28 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { MapPlaceSearchInput } from "./MapPlaceSearchInput"; -const sdk = vi.hoisted(() => ({ fetch: vi.fn() })); -const library = {}; -vi.mock("@vis.gl/react-google-maps", () => ({ useMapsLibrary: () => library })); -vi.mock("@/lib/placesAutocompleteSuggest", () => ({ - AUTOCOMPLETE_SUGGEST_DEBOUNCE_MS: 250, - newAutocompleteSessionToken: () => ({}), - circleLocationBias: (center: unknown, radius: number) => ({ center, radius }), - fetchPlacePredictionsForRequest: sdk.fetch, -})); +import { apiFetch } from "@/lib/api/client"; +import { useMapCenterStore } from "@/stores/map-center-store"; +vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() })); +const sdk = { fetch: vi.mocked(apiFetch) }; +let issued = 0; +function autocompleteCalls() { return sdk.fetch.mock.calls.filter(([url]) => String(url).endsWith("/places/autocomplete")); } let root: Root, host: HTMLDivElement; const confirm = vi.fn(), invalidate = vi.fn(); const p = { placeId: "first", - mainText: { text: "추천" }, - secondaryText: { text: "주소" }, - text: { text: "추천 주소" }, + structuredFormat: { mainText: { text: "추천", matches: null }, secondaryText: { text: "주소", matches: null } }, + text: { text: "추천 주소", matches: null }, }; beforeEach(async () => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); vi.useFakeTimers(); - sdk.fetch.mockReset().mockResolvedValue([p]); + issued = 0; + useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 } }); + sdk.fetch.mockReset().mockImplementation(async (url) => String(url).endsWith("/places/sessions") + ? Response.json({ sessionToken: `token-${++issued}`, expiresAt: Date.now() + 1800000 }, { status: 201 }) + : Response.json({ suggestions: [{ placePrediction: p }] })); confirm.mockReset().mockResolvedValue(true); invalidate.mockReset(); host = document.createElement("div"); @@ -98,25 +98,76 @@ it("rejects blank input and duplicate confirmations", async () => { expect(confirm).toHaveBeenCalledTimes(1); }); it("ignores suggestions returned after clear", async () => { - let finish!: (v: unknown[]) => void; - sdk.fetch.mockReturnValue( - new Promise((r) => { - finish = r; - }), - ); + let finish!: (v: Response) => void; + sdk.fetch.mockImplementation(async url => String(url).endsWith("/places/sessions") + ? Response.json({ sessionToken: "old", expiresAt: Date.now()+60000 }, { status: 201 }) + : new Promise(r => { finish = r; })); await type("원래"); await wait(); await type(""); - await act(async () => finish([p])); + await act(async () => finish(Response.json({ suggestions: [{ placePrediction: p }] }))); expect(host.textContent).not.toContain("추천"); }); it("ignores old suggestions after new input and rotates consumed token for subsequent typing", async () => { await type("첫"); await wait(); - const token = sdk.fetch.mock.calls[0][0].sessionToken; + const token = JSON.parse(autocompleteCalls()[0][1]!.body as string).sessionToken; await key("Enter"); await type("다음"); await wait(); - expect(sdk.fetch.mock.calls[1][0].sessionToken).not.toBe(token); + expect(JSON.parse(autocompleteCalls()[1][1]!.body as string).sessionToken).not.toBe(token); expect(invalidate).toHaveBeenCalled(); }); + +it("debounces issuance and uses server string token and required map coordinates", async () => { + await type("첫"); await type("둘"); await type("셋"); + expect(sdk.fetch).not.toHaveBeenCalled(); await wait(); + expect(issued).toBe(1); expect(autocompleteCalls()).toHaveLength(1); + expect(JSON.parse(autocompleteCalls()[0][1]!.body as string)).toEqual({ sessionToken: "token-1", input: "셋", latitude: 1, longitude: 2 }); +}); +it("null text is safe and Unicode codepoint match includes entire emoji", async () => { + sdk.fetch.mockImplementation(async url => String(url).endsWith("/places/sessions") + ? Response.json({ sessionToken: "unicode", expiresAt: Date.now()+60000 }, { status: 201 }) + : Response.json({ suggestions: [{ placePrediction: { placeId: "unicode", text: null, structuredFormat: { mainText: { text: "가😀나", matches: [{ startOffset: 1, endOffset: 2 }] }, secondaryText: null } } }, { placePrediction: { placeId: "null", text: null, structuredFormat: null } }] })); + await type("원래"); await wait(); + expect(host.querySelector("mark")?.textContent).toBe("😀"); + expect(host.textContent).toContain("가😀나"); + expect(host.textContent).not.toContain("null"); +}); +it("expired token cannot resolve selected prediction", async () => { + await type("원래"); await wait(); + await act(async () => vi.advanceTimersByTimeAsync(1800001)); + await key("ArrowDown"); await key("ArrowDown"); await key("Enter"); + expect(confirm).not.toHaveBeenCalled(); +}); +it("close abandons predictions so immediate reopen Enter cannot consume old token", async () => { + await type("원래"); await wait(); await key("Escape"); + await act(async () => host.querySelector("input")!.dispatchEvent(new FocusEvent("focusin", { bubbles: true }))); + await key("Enter"); + expect(confirm.mock.calls[0]?.[0].prediction).toBeUndefined(); +}); +it("rapid input while session issuance pending stays single-flight and skips stale autocomplete", async () => { + let finish!: (r: Response) => void; + sdk.fetch.mockImplementation(async url => String(url).endsWith("/places/sessions") + ? new Promise(r => { finish = r; }) + : Response.json({ suggestions: [{ placePrediction: p }] })); + await type("첫"); await wait(); await type("다음"); await wait(); + expect(sdk.fetch.mock.calls.filter(([url]) => String(url).endsWith("/places/sessions"))).toHaveLength(1); + await act(async () => finish(Response.json({ sessionToken: "slow", expiresAt: Date.now()+60000 }, { status: 201 }))); + expect(autocompleteCalls()).toHaveLength(1); + expect(JSON.parse(autocompleteCalls()[0][1]!.body as string).input).toBe("다음"); +}); +it("429 respects shared wait without request loop on further input", async () => { + sdk.fetch.mockResolvedValue(Response.json({ code: "RATE_LIMIT_EXCEEDED", message: "잠시 후" }, { status: 429, headers: { "Retry-After": "60" } })); + await type("첫"); await wait(); await type("다음"); await wait(); + expect(sdk.fetch).toHaveBeenCalledTimes(1); + expect(host.textContent).toContain("잠시 후"); +}); +it("nullable structured main text falls back to actual complete prediction text", async () => { + await act(async () => vi.advanceTimersByTimeAsync(61000)); + sdk.fetch.mockImplementation(async url => String(url).endsWith("/places/sessions") + ? Response.json({ sessionToken: "nullable-main", expiresAt: Date.now()+60000 }, { status: 201 }) + : Response.json({ suggestions: [{ placePrediction: { placeId: "id", text: { text: "전체 실제 이름", matches: null }, structuredFormat: { mainText: { text: null, matches: null }, secondaryText: null } } }] })); + await type("원래"); await wait(); + expect(host.querySelectorAll('[role="option"]')[1]?.textContent).toContain("전체 실제 이름"); +}); diff --git a/src/components/search/MapPlaceSearchInput.tsx b/src/components/search/MapPlaceSearchInput.tsx index 51c3b501..df449bf3 100644 --- a/src/components/search/MapPlaceSearchInput.tsx +++ b/src/components/search/MapPlaceSearchInput.tsx @@ -8,14 +8,9 @@ import { useState, type RefObject, } from "react"; -import { useMapsLibrary } from "@vis.gl/react-google-maps"; import { CircleCancelIcon, LocationIcon, SearchIcon } from "@/assets/icons"; -import { - AUTOCOMPLETE_SUGGEST_DEBOUNCE_MS, - circleLocationBias, - fetchPlacePredictionsForRequest, - newAutocompleteSessionToken, -} from "@/lib/placesAutocompleteSuggest"; +import { AutocompleteSearchSession, MAP_AUTOCOMPLETE_DEBOUNCE_MS } from "@/lib/places/autocomplete-session"; +import type { PlacePrediction, PlaceSession, PredictionText } from "@/lib/api/place-sessions"; import type { SearchConfirmation } from "@/lib/places/search-session"; import { useMapCenterStore } from "@/stores/map-center-store"; import { cn } from "@/lib/utils"; @@ -33,9 +28,10 @@ export type MapPlaceSearchInputProps = { }; type Suggestions = { query: string; - items: google.maps.places.PlacePrediction[]; - session: object; + items: PlacePrediction[]; + session: PlaceSession | null; status: "done" | "error"; + error?: string; }; /** Shared web/mobile map search. Original input and prediction/token identity survive confirmation. */ @@ -50,7 +46,6 @@ export function MapPlaceSearchInput({ autoConfirm = false, onQueryChange, }: MapPlaceSearchInputProps) { - const placesLib = useMapsLibrary("places"); const mapCenter = useMapCenterStore((s) => s.mapCenter); const [value, setValue] = useState(initialQuery); const [suggestions, setSuggestions] = useState(null); @@ -59,7 +54,7 @@ export function MapPlaceSearchInput({ const autoQuery = useRef(autoConfirm ? initialQuery : null); const [pending, setPending] = useState(false); const generation = useRef(0); - const token = useRef(null); + const token = useRef(null); const confirmGeneration = useRef(null); const timer = useRef | null>(null); const listId = useId(); @@ -75,6 +70,7 @@ export function MapPlaceSearchInput({ } const invalidateScope = useEffectEvent(() => { cancel(); + token.current?.abandon(); token.current = null; confirmGeneration.current = null; setValue(""); @@ -90,6 +86,8 @@ export function MapPlaceSearchInput({ } return () => { cancel(); + token.current?.abandon(); + token.current = null; }; }, [scopeKey]); @@ -97,49 +95,37 @@ export function MapPlaceSearchInput({ cancel(); setSuggestions(null); setActive(-1); - if (!query.trim() || !placesLib) return; + if (!query.trim() || !mapCenter || !enabled) return; const requestGeneration = generation.current; timer.current = setTimeout(() => { - token.current ??= newAutocompleteSessionToken(); - const requestToken = token.current; - void fetchPlacePredictionsForRequest({ - input: query.trim(), - language: "ko", - sessionToken: - requestToken as google.maps.places.AutocompleteSessionToken, - ...(mapCenter - ? { locationBias: circleLocationBias(mapCenter, 50_000) } - : {}), - }).then( - (items) => { - if (requestGeneration === generation.current) - setSuggestions({ - query, - items, - session: requestToken, - status: "done", - }); + token.current ??= new AutocompleteSearchSession(); + const flow = token.current; + void flow.suggest(query.trim(), mapCenter, () => requestGeneration === generation.current).then( + (result) => { + if (result && requestGeneration === generation.current) + setSuggestions({ query, ...result, status: "done" }); }, - () => { - if (requestGeneration === generation.current) - setSuggestions({ - query, - items: [], - session: requestToken, - status: "error", - }); + (error: unknown) => { + if (requestGeneration === generation.current) { + flow.abandon(); + token.current = null; + setSuggestions({ query, items: [], session: null, status: "error", error: error instanceof Error ? error.message : undefined }); + } }, ); - }, AUTOCOMPLETE_SUGGEST_DEBOUNCE_MS); + }, MAP_AUTOCOMPLETE_DEBOUNCE_MS); } const retryReady = useEffectEvent(() => { if (value.trim() && open) schedule(value); }); useEffect(() => { - if (placesLib && enabled) retryReady(); - }, [placesLib, enabled]); + if (mapCenter && enabled) retryReady(); + }, [mapCenter, enabled]); useEffect(() => { - if (!enabled) cancel(); + if (!enabled) { + cancel(); token.current?.abandon(); token.current = null; + setSuggestions(null); + } }, [enabled]); function update(query: string) { @@ -150,7 +136,7 @@ export function MapPlaceSearchInput({ setOpen(Boolean(query.trim())); confirmGeneration.current = null; setPending(false); - if (!query.trim()) token.current = null; + if (!query.trim()) { token.current?.abandon(); token.current = null; } schedule(query); } async function confirm(index: number) { @@ -161,10 +147,15 @@ export function MapPlaceSearchInput({ : null; const prediction = index > 0 ? ready?.items[index - 1] : ready?.items[0]; if (index > 0 && !prediction) return; - const requestSession = ready?.session ?? token.current ?? {}; + if (ready?.session && ready.session.expiresAt <= Date.now()) { + token.current?.abandon(); token.current = null; + schedule(value); return; + } + const requestSession = ready?.session ?? null; cancel(); const requestGeneration = generation.current; confirmGeneration.current = requestGeneration; + token.current?.abandon(); token.current = null; setPending(true); setOpen(false); @@ -247,7 +238,8 @@ export function MapPlaceSearchInput({ ) return; cancel(); - token.current = null; + token.current?.abandon(); token.current = null; + setSuggestions(null); onInvalidate(false); setOpen(false); }} @@ -268,7 +260,8 @@ export function MapPlaceSearchInput({ } if (event.key === "Escape") { cancel(); - token.current = null; + token.current?.abandon(); token.current = null; + setSuggestions(null); onInvalidate(false); setOpen(false); } @@ -313,8 +306,10 @@ export function MapPlaceSearchInput({ key={prediction.placeId} id={`${listId}-${index + 1}`} active={active === index + 1} - title={prediction.mainText?.text ?? prediction.text.text} - subtitle={prediction.secondaryText?.text} + title={prediction.structuredFormat?.mainText?.text != null + ? prediction.structuredFormat.mainText + : prediction.text} + subtitle={prediction.structuredFormat?.secondaryText?.text ?? undefined} onClick={() => void confirm(index + 1)} place /> @@ -324,7 +319,7 @@ export function MapPlaceSearchInput({ role="status" className="px-3.5 py-3 text-caption-m-regular text-text-subtle" > - 검색 추천을 불러오지 못했어요. 다시 입력해주세요. + {suggestions.error ?? "검색 추천을 불러오지 못했어요. 다시 입력해주세요."} ) : null} @@ -341,7 +336,7 @@ function SuggestionRow({ place = false, }: { id: string; - title: string; + title: string | PredictionText | null; subtitle?: string; active: boolean; onClick: () => void; @@ -364,7 +359,7 @@ function SuggestionRow({ - {title} + {typeof title === "string" ? title : } {subtitle ? ( @@ -376,3 +371,13 @@ function SuggestionRow({ ); } + +/** Backend offsets count Unicode code points, not UTF-16 code units. */ +function HighlightedText({ value }: { value: PredictionText | null }) { + if (!value?.text) return null; + const points = Array.from(value.text); + const matches = value.matches ?? []; + return points.map((point, index) => matches.some(match => index >= match.startOffset && index < match.endOffset) + ? {point} + : point); +} diff --git a/src/lib/api/place-sessions.test.ts b/src/lib/api/place-sessions.test.ts new file mode 100644 index 00000000..c2af0f14 --- /dev/null +++ b/src/lib/api/place-sessions.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { apiFetch } from "./client"; +import { issuePlaceSession, autocompletePlaces, resolvePlaceSession } from "./place-sessions"; +vi.mock("./client", () => ({ apiFetch: vi.fn() })); +beforeEach(() => { vi.mocked(apiFetch).mockReset(); }); +it("authenticated issuance has no body, forwards abort signal and epoch expiry", async () => { + const controller = new AbortController(); + vi.mocked(apiFetch).mockResolvedValue(Response.json({ sessionToken: "server", expiresAt: 1791960000000 }, { status: 201 })); + expect(await issuePlaceSession(controller.signal)).toEqual({ sessionToken: "server", expiresAt: 1791960000000 }); + expect(apiFetch).toHaveBeenCalledExactlyOnceWith(expect.stringContaining("/places/sessions"), { method: "POST", signal: controller.signal }); +}); +it("AbortError propagates without error remapping or paid retry", async () => { + const error = new DOMException("aborted", "AbortError"); + vi.mocked(apiFetch).mockRejectedValue(error); + await expect(resolvePlaceSession("same", "chosen")).rejects.toBe(error); + expect(apiFetch).toHaveBeenCalledTimes(1); +}); +it("nullable autocomplete text and codepoint offsets survive wire unchanged", async () => { + vi.mocked(apiFetch).mockResolvedValue(Response.json({ suggestions: [{ placePrediction: { placeId: "id", text: null, structuredFormat: { mainText: { text: "가😀나", matches: [{ startOffset: 1, endOffset: 2 }] }, secondaryText: null } } }] })); + const data = await autocompletePlaces({ sessionToken: "server", input: "가", latitude: 37, longitude: 127 }); + expect(data.suggestions[0].placePrediction.structuredFormat?.mainText?.matches).toEqual([{ startOffset: 1, endOffset: 2 }]); + expect(data.suggestions[0].placePrediction.text).toBeNull(); +}); diff --git a/src/lib/api/place-sessions.ts b/src/lib/api/place-sessions.ts new file mode 100644 index 00000000..77cd7eb4 --- /dev/null +++ b/src/lib/api/place-sessions.ts @@ -0,0 +1,64 @@ +import { apiFetch } from "./client"; +import { apiUrl, jsonBody, tryParseJson } from "./http"; +import type { PlaceDetail } from "./places"; + +export type PlaceSession = { sessionToken: string; expiresAt: number }; +export type PredictionText = { + text: string | null; + matches: { startOffset: number; endOffset: number }[] | null; +}; +export type PlacePrediction = { + placeId: string; + text: PredictionText | null; + structuredFormat: { + mainText: PredictionText | null; + secondaryText: PredictionText | null; + } | null; +}; +export type PlaceResolveResponse = { + detail: PlaceDetail; + source: "CACHE" | "GOOGLE" | "SESSION_REPLAY"; + cacheStatus: "HIT" | "MISS"; + googleDetailsRequested: boolean; + termination: "NOT_TERMINATED" | "SUCCEEDED"; +}; +export class PlaceSessionError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string, + public readonly retryAfterMs: number | null = null, + ) { super(message); this.name = "PlaceSessionError"; } +} +const bucketWaits = { search: 0, read: 0 }; +async function post(path: string, body?: unknown, signal?: AbortSignal): Promise { + const bucket = path === "/places/resolve" ? "read" : "search"; + if (bucketWaits[bucket] > Date.now()) + throw new PlaceSessionError(429, "RATE_LIMIT_EXCEEDED", "잠시 후 다시 검색해주세요.", bucketWaits[bucket] - Date.now()); + const res = await apiFetch(apiUrl(path), { + method: "POST", ...(body === undefined ? {} : jsonBody(body)), signal, + }); + if (!res.ok) { + const raw = await tryParseJson(res); + const error = raw && typeof raw === "object" ? raw as Record : {}; + const retryAfter = res.headers.get("Retry-After"); + const retryAfterMs = retryAfter == null ? null : /^\d+$/.test(retryAfter) + ? Number(retryAfter) * 1000 : Math.max(0, Date.parse(retryAfter) - Date.now()); + if (res.status === 429) bucketWaits[bucket] = Date.now() + (retryAfterMs != null && Number.isFinite(retryAfterMs) ? retryAfterMs : 60000); + throw new PlaceSessionError(res.status, typeof error.code === "string" ? error.code : "UNKNOWN", + typeof error.message === "string" ? error.message : "장소 검색에 실패했어요. 다시 입력해주세요.", + retryAfterMs != null && Number.isFinite(retryAfterMs) ? retryAfterMs : null); + } + return res.json() as Promise; +} +export function issuePlaceSession(signal?: AbortSignal) { + return post("/places/sessions", undefined, signal); +} +export function autocompletePlaces(params: { + sessionToken: string; input: string; latitude: number; longitude: number; +}, signal?: AbortSignal) { + return post<{ suggestions: { placePrediction: PlacePrediction }[] }>("/places/autocomplete", params, signal); +} +export function resolvePlaceSession(sessionToken: string, googlePlaceId: string, signal?: AbortSignal) { + return post("/places/resolve", { sessionToken, googlePlaceId }, signal); +} diff --git a/src/lib/api/places.ts b/src/lib/api/places.ts index 26c3231e..0a43c89d 100644 --- a/src/lib/api/places.ts +++ b/src/lib/api/places.ts @@ -24,39 +24,34 @@ export type PlaceSearchPageResponse = { }; export type PlaceReview = { - rating: number; - text: string; - authorDisplayName: string; - publishTime: string; - relativePublishTimeDescription: string; + rating: number | null; text: string | null; authorDisplayName: string | null; + publishTime: string | null; relativePublishTimeDescription: string | null; +}; +export type PlaceDateParts = { year: number | null; month: number | null; day: number | null }; +export type PlaceTimePoint = { + day: number | null; hour: number | null; minute: number | null; + date: PlaceDateParts | null; truncated: boolean | null; }; - export type PlaceDetail = { googlePlaceId: string; - name: string; - formattedAddress: string; - location: { lat: number; lng: number }; - primaryType: string; - primaryTypeDisplayName: string; - rating: number | null; - userRatingCount: number | null; - phoneNumber: string; - websiteUri: string; - googleMapsUri: string; + name: string | null; formattedAddress: string | null; + location: { lat: number | null; lng: number | null } | null; + primaryType: string | null; primaryTypeDisplayName: string | null; + rating: number | null; userRatingCount: number | null; + phoneNumber: string | null; websiteUri: string | null; googleMapsUri: string | null; googleMapsLinks: { - placeUri: string | null; - directionsUri: string | null; - writeAReviewUri: string | null; - reviewsUri: string | null; - photosUri: string | null; + placeUri: string | null; directionsUri: string | null; writeAReviewUri: string | null; + reviewsUri: string | null; photosUri: string | null; } | null; regularOpeningHours: { - openNow: boolean; + openNow: boolean | null; secondaryHoursType: string | null; + specialDays: { date: PlaceDateParts | null }[]; + periods: { open: PlaceTimePoint | null; close: PlaceTimePoint | null }[]; weekdayDescriptions: string[]; - nextOpenTime?: string; - nextCloseTime?: string; + nextOpenTime: string | null; nextCloseTime: string | null; } | null; - reviewSummary: string | null; + /** Legacy optional field, absent from the backend GET/resolve DTO. */ + reviewSummary?: string | null; reviews: PlaceReview[]; }; diff --git a/src/lib/places/autocomplete-session.ts b/src/lib/places/autocomplete-session.ts new file mode 100644 index 00000000..555e2876 --- /dev/null +++ b/src/lib/places/autocomplete-session.ts @@ -0,0 +1,29 @@ +import { autocompletePlaces, issuePlaceSession, type PlaceSession } from "@/lib/api/place-sessions"; + +export const MAP_AUTOCOMPLETE_DEBOUNCE_MS = 250; +/** One input flow: lazy issuance, serialized autocomplete and explicit abandonment. */ +export class AutocompleteSearchSession { + private controller = new AbortController(); + private issued: Promise | null = null; + private session: PlaceSession | null = null; + private queue: Promise = Promise.resolve(); + abandon() { this.controller.abort(); } + async suggest(input: string, center: google.maps.LatLngLiteral, isCurrent: () => boolean) { + const signal = this.controller.signal; + if (this.session && this.session.expiresAt <= Date.now()) { + this.session = null; this.issued = null; + } + this.issued ??= issuePlaceSession(signal).then(session => { this.session = session; return session; }); + const issued = this.issued; + const work = this.queue.catch(() => {}).then(async () => { + const session = await issued; + if (signal.aborted || !isCurrent()) return null; + const result = await autocompletePlaces({ sessionToken: session.sessionToken, input, + latitude: center.lat, longitude: center.lng }, signal); + if (signal.aborted || !isCurrent() || session.expiresAt <= Date.now()) return null; + return { session, items: result.suggestions.map(s => s.placePrediction) }; + }); + this.queue = work; + return work; + } +} diff --git a/src/lib/places/place-detail-cache.test.tsx b/src/lib/places/place-detail-cache.test.tsx index 7176dfcc..5438d7eb 100644 --- a/src/lib/places/place-detail-cache.test.tsx +++ b/src/lib/places/place-detail-cache.test.tsx @@ -4,16 +4,18 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { expect, it, vi } from "vitest"; import { fetchPlaceDetail, placeDetailQueryKey } from "./place-queries"; -import { terminatePredictionSession } from "./search-session"; +import { confirmPlaceSearch } from "./search-session"; import { registerQueryClient } from "@/lib/query-client"; import { usePlaceDetailData } from "@/components/place/usePlaceDetailData"; +import { apiFetch } from "@/lib/api/client"; +vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() })); import { requestPlaceDetail, type PlaceDetail } from "@/lib/api/places"; vi.mock("@/lib/api/places", async (original) => ({ ...(await original()), requestPlaceDetail: vi.fn(), })); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -it("SDK coordinates never seed full detail; later panels reuse full information without another request", async () => { +it("resolved full raw response seeds canonical cache and later panels reuse it with zero GET requests", async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -37,7 +39,7 @@ it("SDK coordinates never seed full detail; later panels reuse full information writeAReviewUri: null, photosUri: null, }, - regularOpeningHours: { openNow: true, weekdayDescriptions: ["월요일 9시"] }, + regularOpeningHours: { openNow: true, weekdayDescriptions: ["월요일 9시"], secondaryHoursType: null, specialDays: [], periods: [], nextOpenTime: null, nextCloseTime: null }, reviewSummary: "요약", reviews: [ { @@ -50,18 +52,11 @@ it("SDK coordinates never seed full detail; later panels reuse full information ], }; vi.mocked(requestPlaceDetail).mockReset().mockResolvedValue(detail); - await terminatePredictionSession( - { - placeId: "one", - toPlace: () => ({ - fetchFields: async () => {}, - location: { lat: () => 1, lng: () => 2 }, - }), - } as unknown as google.maps.places.PlacePrediction, - {}, - ); - expect(client.getQueryData(placeDetailQueryKey("one"))).toBeUndefined(); - await fetchPlaceDetail("places/one"); + vi.mocked(apiFetch).mockResolvedValue(Response.json({ detail, source: "GOOGLE", cacheStatus: "MISS", googleDetailsRequested: true, termination: "SUCCEEDED" })); + await confirmPlaceSearch({ query: "카페", kind: "specific", prediction: { placeId: "one", text: null, structuredFormat: null }, session: { sessionToken: "token", expiresAt: Date.now()+60000 }, mapCenter: null, isCurrent: () => true, search: async () => [] }); + const cached = client.getQueryData(placeDetailQueryKey("one")); + expect(cached).toEqual(detail); + expect(await fetchPlaceDetail("places/one")).toBe(cached); let view: ReturnType; function Panel() { const result = usePlaceDetailData("one"); @@ -78,7 +73,8 @@ it("SDK coordinates never seed full detail; later panels reuse full information , ), ); - expect(requestPlaceDetail).toHaveBeenCalledTimes(1); + expect(requestPlaceDetail).not.toHaveBeenCalled(); + expect(apiFetch).toHaveBeenCalledTimes(1); expect(view!.data).toMatchObject({ openNow: true, phone: "전화", diff --git a/src/lib/places/place-queries.ts b/src/lib/places/place-queries.ts index 70fdebed..005ff847 100644 --- a/src/lib/places/place-queries.ts +++ b/src/lib/places/place-queries.ts @@ -34,7 +34,7 @@ export function placePreviewQueryKey(googlePlaceId: string) { } export function normalizePreviewLocation( - location: PlacePreviewResponse["location"] | { lat?: number; lng?: number }, + location: PlacePreviewResponse["location"] | { lat?: number | null; lng?: number | null }, ): { lat: number; lng: number } | undefined { if (!location || typeof location !== "object") return undefined; diff --git a/src/lib/places/search-session.test.ts b/src/lib/places/search-session.test.ts index 3bb472b8..34a5b244 100644 --- a/src/lib/places/search-session.test.ts +++ b/src/lib/places/search-session.test.ts @@ -1,163 +1,147 @@ -import { describe, expect, it, vi } from "vitest"; -import { - confirmPlaceSearch, - terminatePredictionSession, -} from "./search-session"; - -function prediction(id = "first", location = { lat: 37, lng: 127 }) { - const fetchFields = vi.fn().mockResolvedValue(undefined); - const place = { - fetchFields, - location: { lat: () => location.lat, lng: () => location.lng }, - }; - return { - placeId: id, - mainText: { text: "추천 이름" }, - secondaryText: { text: "주소" }, - toPlace: vi.fn(() => place), - fetchFields, - }; -} +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { confirmPlaceSearch, terminatePredictionSession } from "./search-session"; +import { apiFetch } from "@/lib/api/client"; +import { QueryClient } from "@tanstack/react-query"; +import { registerQueryClient } from "@/lib/query-client"; +import { placeDetailQueryKey } from "./place-queries"; +vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() })); +const detail = { + googlePlaceId: "first", name: "추천 이름", formattedAddress: "주소", + location: { lat: 37, lng: 127 }, primaryType: "cafe", primaryTypeDisplayName: "카페", + rating: 4.5, userRatingCount: 2, phoneNumber: null, websiteUri: null, + googleMapsUri: null, googleMapsLinks: null, regularOpeningHours: null, reviews: [], +}; +const response = { detail, source: "GOOGLE", cacheStatus: "MISS", googleDetailsRequested: true, termination: "SUCCEEDED" }; +let client: QueryClient; +let clock = Date.now(); +beforeEach(() => { + clock += 61000; vi.spyOn(Date, "now").mockReturnValue(clock); + client = new QueryClient(); registerQueryClient(client); + vi.mocked(apiFetch).mockReset().mockResolvedValue(Response.json(response)); +}); function setup(overrides = {}) { - const p = prediction(); const search = vi.fn().mockResolvedValue([{ googlePlaceId: "other" }]); - const args = { - query: "원래 검색어", - kind: "whole" as const, - prediction: p as unknown as google.maps.places.PlacePrediction, - session: {}, - mapCenter: { lat: 1, lng: 2 }, - isCurrent: () => true, - search, - ...overrides, - }; - return { args, p, search }; + return { search, args: { query: "원래 검색어", kind: "whole" as const, + prediction: { placeId: "first", text: null, structuredFormat: null }, + session: { sessionToken: "server-token", expiresAt: Date.now() + 60000 }, + mapCenter: { lat: 1, lng: 2 }, isCurrent: () => true, search, ...overrides } }; } -describe("session-connected search", () => { - it("actually terminates once with location only, returning partial coordinates", async () => { - const p = prediction(); - const session = {}; - const a = terminatePredictionSession( - p as unknown as google.maps.places.PlacePrediction, - session, - ); - const b = terminatePredictionSession( - p as unknown as google.maps.places.PlacePrediction, - session, - ); - expect(await a).toEqual({ - kind: "coordinates", - googlePlaceId: "first", - location: { lat: 37, lng: 127 }, - termination: "completed", - }); - await b; - expect(p.toPlace).toHaveBeenCalledTimes(1); - expect(p.fetchFields).toHaveBeenCalledExactlyOnceWith({ - fields: ["location"], - }); +describe("server session confirmation", () => { + it("resolves same server token once and seeds full raw detail before publishing anchor", async () => { + const { args, search } = setup(); + const onAnchor = vi.fn(() => expect(client.getQueryData(placeDetailQueryKey("first"))).toEqual(detail)); + const result = await confirmPlaceSearch({ ...args, onAnchor }); + expect(apiFetch).toHaveBeenCalledExactlyOnceWith(expect.stringContaining("/places/resolve"), expect.objectContaining({ method: "POST", body: JSON.stringify({ sessionToken: "server-token", googlePlaceId: "first" }) })); + expect(result?.anchor?.detail).toEqual(detail); + expect(result?.termination).toBe("SUCCEEDED"); + expect(onAnchor).toHaveBeenCalledTimes(1); + expect(search).toHaveBeenCalledExactlyOnceWith({ query: "원래 검색어", latitude: 37, longitude: 127, radius: 5000 }); }); - it("searches original query within 5km around FIRST prediction, preserves it absent from results", async () => { - const { args, p, search } = setup(); + it("frontend cache HIT still resolves; keeps server HIT distinct from termination", async () => { + client.setQueryData(placeDetailQueryKey("first"), detail); + vi.mocked(apiFetch).mockResolvedValue(Response.json({ ...response, source: "CACHE", cacheStatus: "HIT", googleDetailsRequested: false, termination: "NOT_TERMINATED" })); + const { args } = setup({ kind: "specific" }); const result = await confirmPlaceSearch(args); - expect(search).toHaveBeenCalledExactlyOnceWith({ - query: "원래 검색어", - latitude: 37, - longitude: 127, - radius: 5000, - }); - expect(result?.anchor?.googlePlaceId).toBe(p.placeId); - expect(result?.items).toEqual([{ googlePlaceId: "other" }]); - expect(result?.fallback).toBe(false); + expect(apiFetch).toHaveBeenCalledTimes(1); + expect(result?.termination).toBe("NOT_TERMINATED"); + expect(result?.anchor?.source).toBe("CACHE"); }); - it("specific prediction makes zero Text Search calls", async () => { + it("specific resolves full response with zero Text Search", async () => { const { args, search } = setup({ kind: "specific" }); - expect((await confirmPlaceSearch(args))?.anchor?.googlePlaceId).toBe( - "first", - ); + expect((await confirmPlaceSearch(args))?.anchor?.detail.rating).toBe(4.5); expect(search).not.toHaveBeenCalled(); }); - it.each(["absent", "failed"])( - "falls back to map center for %s first prediction without trying another", - async (mode) => { - const { args, p, search } = setup( - mode === "absent" ? { prediction: undefined } : {}, - ); - if (mode === "failed") - p.fetchFields.mockRejectedValue(new Error("details failed")); - expect((await confirmPlaceSearch(args))?.fallback).toBe(true); - expect(search).toHaveBeenCalledExactlyOnceWith({ - query: "원래 검색어", - latitude: 1, - longitude: 2, - radius: 5000, - }); - }, - ); - it("rejects no-center fallback and blank input without outbound calls", async () => { - const { args, search } = setup({ prediction: undefined, mapCenter: null }); - await expect(confirmPlaceSearch(args)).rejects.toThrow("지도 위치"); - await expect(confirmPlaceSearch({ ...args, query: " " })).rejects.toThrow( - "검색어", - ); - expect(search).not.toHaveBeenCalled(); + it.each(["absent", "failed", "null coordinates"])("whole uses map-center fallback for %s without another Google call", async mode => { + const { args, search } = setup(mode === "absent" ? { prediction: undefined } : {}); + if (mode === "failed") vi.mocked(apiFetch).mockResolvedValue(Response.json({ code: "EXTERNAL_API_ERROR", message: "실패" }, { status: 502 })); + if (mode === "null coordinates") vi.mocked(apiFetch).mockResolvedValue(Response.json({ ...response, detail: { ...detail, location: null } })); + const result = await confirmPlaceSearch(args); + expect(result?.fallback).toBe(true); + expect(search).toHaveBeenCalledExactlyOnceWith({ query: "원래 검색어", latitude: 1, longitude: 2, radius: 5000 }); + expect(apiFetch).toHaveBeenCalledTimes(mode === "absent" ? 0 : 1); + if (mode === "null coordinates") expect(result?.termination).toBe("SUCCEEDED"); }); - it("suppresses late Details and does not start search", async () => { - let finish!: () => void; - let current = true; - const { args, p, search } = setup({ isCurrent: () => current }); - p.fetchFields.mockImplementation( - () => - new Promise((r) => { - finish = r; - }), - ); - const pending = confirmPlaceSearch(args); - current = false; - finish(); + it("specific null coordinates cannot fake location or make supplementary requests", async () => { + vi.mocked(apiFetch).mockResolvedValue(Response.json({ ...response, detail: { ...detail, location: null } })); + const { args, search } = setup({ kind: "specific" }); + await expect(confirmPlaceSearch(args)).rejects.toThrow("좌표"); + expect(apiFetch).toHaveBeenCalledTimes(1); expect(search).not.toHaveBeenCalled(); + }); + it("late resolve cannot seed scope cache or publish search", async () => { + let finish!: (v: Response) => void; let current = true; + vi.mocked(apiFetch).mockReturnValue(new Promise(r => { finish = r; })); + const { args, search } = setup({ isCurrent: () => current }); + const pending = confirmPlaceSearch(args); current = false; finish(Response.json(response)); expect(await pending).toBeNull(); + expect(client.getQueryData(placeDetailQueryKey("first"))).toBeUndefined(); expect(search).not.toHaveBeenCalled(); }); - it("suppresses late Text Search", async () => { - let finish!: (items: unknown[]) => void; - let current = true; + it("late Text Search suppressed", async () => { + let finish!: (v: unknown[]) => void; let current = true; const { args, search } = setup({ isCurrent: () => current }); - search.mockImplementation( - () => - new Promise((r) => { - finish = r; - }), - ); - const pending = confirmPlaceSearch(args); - await vi.waitFor(() => expect(search).toHaveBeenCalled()); - current = false; - finish([]); - expect(await pending).toBeNull(); + search.mockReturnValue(new Promise(r => { finish = r; })); + const pending = confirmPlaceSearch(args); await vi.waitFor(() => expect(search).toHaveBeenCalled()); + current = false; finish([]); expect(await pending).toBeNull(); + }); + it("duplicate choices reuse same operation, conflicting place never requests", async () => { + const { args } = setup(); + const a = terminatePredictionSession(args.prediction, args.session); + const b = terminatePredictionSession(args.prediction, args.session); + await Promise.all([a,b]); expect(apiFetch).toHaveBeenCalledTimes(1); + await expect(terminatePredictionSession({ ...args.prediction, placeId: "other" }, args.session)).rejects.toThrow("이미"); + expect(apiFetch).toHaveBeenCalledTimes(1); + }); + it.each([[400,"PLACE_SESSION_INVALID"],[409,"PLACE_SESSION_NOT_STARTED"],[409,"PLACE_SESSION_CONFLICT"],[502,"EXTERNAL_API_ERROR"],[409,"PLACE_SESSION_UNCERTAIN"],[503,"PLACE_SESSION_UNAVAILABLE"],[429,"RATE_LIMIT_EXCEEDED"]])("%s %s never automatically retries paid resolve", async (status, code) => { + vi.mocked(apiFetch).mockResolvedValue(Response.json({ code, message: "실패" }, { status: Number(status) })); + const { args } = setup({ kind: "specific" }); + await expect(confirmPlaceSearch(args)).rejects.toMatchObject({ status, code }); + await expect(confirmPlaceSearch(args)).rejects.toMatchObject({ status, code }); + expect(apiFetch).toHaveBeenCalledTimes(1); }); - it("invalid coordinates cannot become a complete detail or a completed termination", async () => { - const p = prediction("bad", { lat: NaN, lng: 2 }); - await expect( - terminatePredictionSession( - p as unknown as google.maps.places.PlacePrediction, - {}, - ), - ).rejects.toThrow("좌표"); + it("MISS replay preserves SUCCEEDED despite no Google request this time", async () => { + vi.mocked(apiFetch).mockResolvedValue(Response.json({ ...response, source: "SESSION_REPLAY", googleDetailsRequested: false })); + const { args } = setup({ kind: "specific" }); + expect((await confirmPlaceSearch(args))?.anchor).toMatchObject({ source: "SESSION_REPLAY", cacheStatus: "MISS", googleDetailsRequested: false, termination: "SUCCEEDED" }); + }); + it("blank/no-center never makes outbound requests", async () => { + const { args, search } = setup({ prediction: undefined, mapCenter: null }); + await expect(confirmPlaceSearch(args)).rejects.toThrow("지도 위치"); + await expect(confirmPlaceSearch({ ...args, query: " " })).rejects.toThrow("검색어"); + expect(search).not.toHaveBeenCalled(); expect(apiFetch).not.toHaveBeenCalled(); }); }); -it("a consumed session cannot confirm a different prediction identity", async () => { - const session = {}; - const first = prediction("first"); - const other = prediction("other"); - await terminatePredictionSession( - first as unknown as google.maps.places.PlacePrediction, - session, - ); - await expect( - terminatePredictionSession( - other as unknown as google.maps.places.PlacePrediction, - session, - ), - ).rejects.toThrow("이미"); - expect(other.toPlace).not.toHaveBeenCalled(); +it("IN_PROGRESS retries once after delay with identical token/place, then accepts replay", async () => { + vi.mocked(apiFetch).mockResolvedValueOnce(Response.json({ code: "PLACE_SESSION_IN_PROGRESS", message: "진행 중" }, { status: 409 })).mockResolvedValueOnce(Response.json({ ...response, source: "SESSION_REPLAY", googleDetailsRequested: false })); + const { args } = setup({ kind: "specific" }); + const result = await confirmPlaceSearch(args); + expect(result?.termination).toBe("SUCCEEDED"); + expect(apiFetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(apiFetch).mock.calls.map(([,init]) => init!.body)).toEqual([JSON.stringify({ sessionToken: "server-token", googlePlaceId: "first" }), JSON.stringify({ sessionToken: "server-token", googlePlaceId: "first" })]); +}); +it("persistent IN_PROGRESS is bounded and no subsequent confirmation starts a loop", async () => { + vi.mocked(apiFetch).mockImplementation(async () => Response.json({ code: "PLACE_SESSION_IN_PROGRESS", message: "진행 중" }, { status: 409 })); + const { args } = setup({ kind: "specific" }); + await expect(confirmPlaceSearch(args)).rejects.toMatchObject({ code: "PLACE_SESSION_IN_PROGRESS" }); + await expect(confirmPlaceSearch(args)).rejects.toMatchObject({ code: "PLACE_SESSION_IN_PROGRESS" }); + expect(apiFetch).toHaveBeenCalledTimes(2); +}); + +it("abandoned scope during IN_PROGRESS wait never starts second resolve", async () => { + let current = true; + vi.mocked(apiFetch).mockImplementation(async () => Response.json({ code: "PLACE_SESSION_IN_PROGRESS", message: "진행 중" }, { status: 409 })); + const { args } = setup({ kind: "specific", isCurrent: () => current }); + const pending = confirmPlaceSearch(args); + await new Promise(r => setTimeout(r, 20)); current = false; + expect(await pending).toBeNull(); expect(apiFetch).toHaveBeenCalledTimes(1); +}); +it("stale resolve cannot overwrite a pre-existing canonical detail", async () => { + const existing = { ...detail, name: "현재 캐시" }; + client.setQueryData(placeDetailQueryKey("first"), existing); + let current = true, finish!: (r: Response) => void; + vi.mocked(apiFetch).mockReturnValue(new Promise(r => { finish = r; })); + const { args } = setup({ kind: "specific", isCurrent: () => current }); + const pending = confirmPlaceSearch(args); current = false; + finish(Response.json(response)); expect(await pending).toBeNull(); + expect(client.getQueryData(placeDetailQueryKey("first"))).toBe(existing); }); diff --git a/src/lib/places/search-session.ts b/src/lib/places/search-session.ts index d1e03eb8..4c20058d 100644 --- a/src/lib/places/search-session.ts +++ b/src/lib/places/search-session.ts @@ -1,93 +1,78 @@ -/** Location-only SDK response. Never write this value to the full detail cache. */ -export type PredictionCoordinates = { - kind: "coordinates"; - googlePlaceId: string; - location: google.maps.LatLngLiteral; - termination: "completed"; -}; +import { + resolvePlaceSession, PlaceSessionError, + type PlacePrediction, type PlaceResolveResponse, type PlaceSession, +} from "@/lib/api/place-sessions"; +import { getQueryClient } from "@/lib/query-client"; +import { placeDetailQueryKey, normalizePreviewLocation } from "./place-queries"; + export type SearchConfirmation = { query: string; kind: "whole" | "specific"; - prediction?: google.maps.places.PlacePrediction; - /** Identity of the token used by the actual prediction request. */ - session: object; + prediction?: PlacePrediction; + /** Issued token from the successful autocomplete that supplied this prediction. */ + session: PlaceSession | null; generation: number; }; -const terminations = new WeakMap< - object, - { googlePlaceId: string; promise: Promise } ->(); - -/** A consumed session remains single-flight even if its terminating request fails. */ +export type ResolvedSearchAnchor = PlaceResolveResponse & { + googlePlaceId: string; + location: google.maps.LatLngLiteral; +}; +const resolutions = new WeakMap; +}>(); +/** Errors remain tied to this operation; cancel/timeout never prove Google was not called. */ export function terminatePredictionSession( - prediction: google.maps.places.PlacePrediction, - session: object, -): Promise { - const pending = terminations.get(session); - if (pending) - return pending.googlePlaceId === prediction.placeId - ? pending.promise - : Promise.reject( - new Error("이미 확인한 검색 세션입니다. 다시 검색해주세요."), - ); - const request = (async (): Promise => { - const place = prediction.toPlace(); - await place.fetchFields({ fields: ["location"] }); - const lat = place.location?.lat(); - const lng = place.location?.lng(); - if ( - lat == null || - lng == null || - !Number.isFinite(lat) || - !Number.isFinite(lng) || - Math.abs(lat) > 90 || - Math.abs(lng) > 180 - ) { - throw new Error("장소 좌표를 확인할 수 없어요."); + prediction: PlacePrediction, session: PlaceSession | null, isCurrent: () => boolean = () => true, +): Promise { + if (!session || session.expiresAt <= Date.now()) + return Promise.reject(new PlaceSessionError(400, "PLACE_SESSION_INVALID", "검색 세션이 만료됐어요. 다시 입력해주세요.")); + const existing = resolutions.get(session); + if (existing) return existing.googlePlaceId === prediction.placeId ? existing.promise + : Promise.reject(new PlaceSessionError(409, "PLACE_SESSION_CONFLICT", "이미 확인한 검색 세션입니다. 다시 검색해주세요.")); + const promise = (async () => { + // Only IN_PROGRESS is known to permit a bounded delayed same-token/place retry. + for (let attempt = 0; ; attempt++) { + try { return await resolvePlaceSession(session.sessionToken, prediction.placeId); } + catch (error) { + if (!(error instanceof PlaceSessionError) || error.code !== "PLACE_SESSION_IN_PROGRESS" || attempt >= 1) throw error; + await new Promise(r => setTimeout(r, 300)); + if (!isCurrent()) throw new DOMException("Search abandoned", "AbortError"); + } } - return { - kind: "coordinates", - googlePlaceId: prediction.placeId, - location: { lat, lng }, - termination: "completed", - }; })(); - terminations.set(session, { - googlePlaceId: prediction.placeId, - promise: request, - }); - return request; + resolutions.set(session, { googlePlaceId: prediction.placeId, promise }); + return promise; } - -export async function confirmPlaceSearch( - args: Omit & { - mapCenter: google.maps.LatLngLiteral | null; - isCurrent: () => boolean; - onAnchor?: (anchor: PredictionCoordinates) => void; - search: (params: { - query: string; - latitude: number; - longitude: number; - radius: number; - }) => Promise; - }, -): Promise<{ - anchor: PredictionCoordinates | null; - items: T[]; - fallback: boolean; - termination: "completed" | "failed" | "not-requested"; +export async function confirmPlaceSearch(args: Omit & { + mapCenter: google.maps.LatLngLiteral | null; + isCurrent: () => boolean; + onAnchor?: (anchor: ResolvedSearchAnchor) => void; + onResolved?: (resolved: PlaceResolveResponse) => void; + search: (params: { query: string; latitude: number; longitude: number; radius: number }) => Promise; +}): Promise<{ + anchor: ResolvedSearchAnchor | null; items: T[]; fallback: boolean; + termination: PlaceResolveResponse["termination"] | "failed" | "not-requested"; } | null> { const query = args.query.trim(); if (!query) throw new Error("검색어를 입력해주세요."); if (!args.isCurrent()) return null; - let anchor: PredictionCoordinates | null = null; - let termination: "completed" | "failed" | "not-requested" = "not-requested"; + let anchor: ResolvedSearchAnchor | null = null; + let termination: PlaceResolveResponse["termination"] | "failed" | "not-requested" = "not-requested"; if (args.prediction) { try { - anchor = await terminatePredictionSession(args.prediction, args.session); - termination = "completed"; + const resolved = await terminatePredictionSession(args.prediction, args.session, args.isCurrent); + if (!args.isCurrent()) return null; + termination = resolved.termination; + // Seed the complete untouched server DTO BEFORE a detail consumer mounts. + getQueryClient()?.setQueryData(placeDetailQueryKey(resolved.detail.googlePlaceId), resolved.detail); + args.onResolved?.(resolved); + const location = normalizePreviewLocation(resolved.detail.location); + if (location && Math.abs(location.lat) <= 90 && Math.abs(location.lng) <= 180) + anchor = { ...resolved, googlePlaceId: resolved.detail.googlePlaceId, location }; + else if (args.kind === "specific") throw new Error("장소 좌표를 확인할 수 없어요."); } catch (error) { - termination = "failed"; + // Coordinate absence does not erase a verified server termination. + if (termination === "not-requested") termination = "failed"; if (args.kind === "specific" && args.isCurrent()) throw error; } } @@ -99,12 +84,7 @@ export async function confirmPlaceSearch( } const center = anchor?.location ?? args.mapCenter; if (!center) throw new Error("지도 위치를 확인한 뒤 다시 검색해주세요."); - const items = await args.search({ - query, - latitude: center.lat, - longitude: center.lng, - radius: 5000, - }); + const items = await args.search({ query, latitude: center.lat, longitude: center.lng, radius: 5000 }); if (!args.isCurrent()) return null; return { anchor, items, fallback: !anchor, termination }; } diff --git a/src/stores/place-search-store.ts b/src/stores/place-search-store.ts index f549957e..e6423035 100644 --- a/src/stores/place-search-store.ts +++ b/src/stores/place-search-store.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import type { PlaceResolveResponse } from "@/lib/api/place-sessions"; import type { SearchRankBucket } from "@/lib/analytics/context"; import type { PlaceSearchResult } from "@/hooks/usePlacesSearch"; @@ -15,7 +16,8 @@ type PlaceSearchState = { status: "idle" | "pending" | "done" | "error"; error: string | null; fallback: boolean; - termination: "completed" | "failed" | "not-requested"; + termination: PlaceResolveResponse["termination"] | "failed" | "not-requested"; + resolution: Omit | null; setScope: (scope: string) => void; invalidate: () => number; select: (id: string) => void; @@ -33,6 +35,7 @@ const empty = { error: null, fallback: false, termination: "not-requested" as const, + resolution: null, }; export const usePlaceSearchStore = create((set, get) => ({ ...empty, From 4b2dafab2db7c9386c830f617f27a0d4ea25add1 Mon Sep 17 00:00:00 2001 From: minbros Date: Wed, 14 Oct 2026 20:31:11 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix(map):=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=B0=BD=EA=B3=BC=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EB=B2=84=ED=8A=BC=20=EA=B0=80=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/map/MapDiscoverToolbar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/map/MapDiscoverToolbar.tsx b/src/components/map/MapDiscoverToolbar.tsx index 63154f51..3fbe3155 100644 --- a/src/components/map/MapDiscoverToolbar.tsx +++ b/src/components/map/MapDiscoverToolbar.tsx @@ -52,7 +52,7 @@ export function MapDiscoverToolbar({
    @@ -65,6 +65,7 @@ export function MapDiscoverToolbar({ {...mapToolbarPanelMotion} > From 335c725d4a7fe37efb7d1443ae6097ab90282def Mon Sep 17 00:00:00 2001 From: minbros Date: Wed, 14 Oct 2026 23:48:37 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix(search):=20=EA=B2=80=EC=83=89=20GUI?= =?UTF-8?q?=EC=99=80=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=83=90=EC=83=89=20=ED=9D=90=EB=A6=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map/_components/MobileRoutePlaceCard.tsx | 2 +- src/assets/icons/map-search-bookmark.svg | 5 ++ src/assets/icons/map-search-calendar.svg | 14 +++++ src/assets/icons/map-search-chat.svg | 10 +++ src/assets/icons/map-search-star.svg | 5 ++ src/components/map/MapDiscoverToolbar.tsx | 12 ++-- src/components/map/MapPlaceSearch.tsx | 2 +- src/components/map/MapWithDetailPanel.tsx | 2 +- .../map/MobileMapSearch.history.test.tsx | 8 +-- src/components/map/MobileMapSearch.tsx | 4 ++ .../map/MobileSearchResultCarousel.tsx | 63 +++++++++---------- src/components/map/useMapPlaceSearch.test.tsx | 4 ++ src/components/map/useMapPlaceSearch.ts | 4 +- .../search/MapMobileSearchScreen.tsx | 15 +---- .../search/MapPlaceSearchInput.test.tsx | 2 +- src/components/search/MapPlaceSearchInput.tsx | 37 +++++++---- 16 files changed, 113 insertions(+), 76 deletions(-) create mode 100644 src/assets/icons/map-search-bookmark.svg create mode 100644 src/assets/icons/map-search-calendar.svg create mode 100644 src/assets/icons/map-search-chat.svg create mode 100644 src/assets/icons/map-search-star.svg diff --git a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx index 9ea90a49..2f8f5b78 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx @@ -150,7 +150,7 @@ export function MobileRouteExpandedShell({ > -
    {children}
    +
    {children}
    ); } diff --git a/src/assets/icons/map-search-bookmark.svg b/src/assets/icons/map-search-bookmark.svg new file mode 100644 index 00000000..362fd52a --- /dev/null +++ b/src/assets/icons/map-search-bookmark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/map-search-calendar.svg b/src/assets/icons/map-search-calendar.svg new file mode 100644 index 00000000..ac49a978 --- /dev/null +++ b/src/assets/icons/map-search-calendar.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/icons/map-search-chat.svg b/src/assets/icons/map-search-chat.svg new file mode 100644 index 00000000..33985b9b --- /dev/null +++ b/src/assets/icons/map-search-chat.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/map-search-star.svg b/src/assets/icons/map-search-star.svg new file mode 100644 index 00000000..97f49353 --- /dev/null +++ b/src/assets/icons/map-search-star.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/map/MapDiscoverToolbar.tsx b/src/components/map/MapDiscoverToolbar.tsx index 3fbe3155..9174717f 100644 --- a/src/components/map/MapDiscoverToolbar.tsx +++ b/src/components/map/MapDiscoverToolbar.tsx @@ -104,20 +104,20 @@ function MobileMapSearchBar() { } return ( -
    +
    {textQuery ? ( @@ -125,7 +125,7 @@ function MobileMapSearchBar() { type="button" onClick={clearTextSearch} aria-label="검색 결과 지우기" - className="flex h-12 w-8 shrink-0 items-center justify-center" + className="flex h-11 w-8 shrink-0 items-center justify-center" > diff --git a/src/components/map/MapPlaceSearch.tsx b/src/components/map/MapPlaceSearch.tsx index 88820c57..6d16c1af 100644 --- a/src/components/map/MapPlaceSearch.tsx +++ b/src/components/map/MapPlaceSearch.tsx @@ -97,7 +97,7 @@ export function MapPlaceSearch({ mobile }: { mobile: boolean }) {
    )} {mobile && feedback ? ( -
    +
    {feedback}
    ) : null} diff --git a/src/components/map/MapWithDetailPanel.tsx b/src/components/map/MapWithDetailPanel.tsx index 727d02e2..ad757ebc 100644 --- a/src/components/map/MapWithDetailPanel.tsx +++ b/src/components/map/MapWithDetailPanel.tsx @@ -44,7 +44,7 @@ export function MapWithDetailPanel({ aria-hidden={hidden || undefined} style={hidden ? { display: "none" } : undefined} className={cn( - "relative h-full min-h-0 min-w-0 flex-1 basis-0 overflow-hidden", + "relative h-full min-h-0 min-w-0 flex-1 basis-0 overflow-clip", mobileInline ? "flex" : "hidden border-l border-gray-border s1:flex", diff --git a/src/components/map/MobileMapSearch.history.test.tsx b/src/components/map/MobileMapSearch.history.test.tsx index 1e3b31dd..9badd0ff 100644 --- a/src/components/map/MobileMapSearch.history.test.tsx +++ b/src/components/map/MobileMapSearch.history.test.tsx @@ -32,7 +32,7 @@ vi.mock("./MobileSearchResultCarousel", () => ({ }, })); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -it("saved history selection closes an existing POI and exposes its new collapsed card/map selection", async () => { +it("saved history selection replaces the existing POI with the chosen place detail", async () => { useSessionStore.setState({ currentRoomId: "room" }); usePlaceSearchStore.getState().clear(); usePlaceSearchStore.getState().setScope("7:room"); @@ -93,10 +93,8 @@ it("saved history selection closes an existing POI and exposes its new collapsed (button) => button.textContent?.includes("저장 장소"), )!; await act(async () => (saved as HTMLButtonElement).click()); - expect(host.querySelector("[data-detail]")?.textContent).toBe(""); - expect(host.querySelector("[data-search-card]")?.textContent).toBe( - "저장 장소", - ); + expect(host.querySelector("[data-detail]")?.textContent).toBe("저장 장소"); + expect(host.querySelector("[data-search-card]")).toBeNull(); expect(usePlaceSearchStore.getState()).toMatchObject({ selectedId: "saved", expanded: false, diff --git a/src/components/map/MobileMapSearch.tsx b/src/components/map/MobileMapSearch.tsx index f373ae32..df9530f9 100644 --- a/src/components/map/MobileMapSearch.tsx +++ b/src/components/map/MobileMapSearch.tsx @@ -95,6 +95,10 @@ export function MobileMapSearch({ }); useSearchRecenterStore.getState().clearSearchSnapshot(); setActiveSearchMapPins(placeSearchResultsToMapPins([item])); + setSelectedPlace(item, { + itinerarySource: "search", + analyticsSource: "search", + }); useMobileMapSearchStore.setState({ textQuery: item.name }); closeSearch(); return { diff --git a/src/components/map/MobileSearchResultCarousel.tsx b/src/components/map/MobileSearchResultCarousel.tsx index 9bc167fc..f93fc9e2 100644 --- a/src/components/map/MobileSearchResultCarousel.tsx +++ b/src/components/map/MobileSearchResultCarousel.tsx @@ -3,12 +3,10 @@ import type { SearchRankBucket } from "@/lib/analytics/context"; import { useState } from "react"; import { toast } from "sonner"; -import { - CalendarAddIcon, - ChatIcon, - ReactionStarIcon, - SavedIcon, -} from "@/assets/icons"; +import CalendarIcon from "@/assets/icons/map-search-calendar.svg"; +import ChatIcon from "@/assets/icons/map-search-chat.svg"; +import StarIcon from "@/assets/icons/map-search-star.svg"; +import BookmarkIcon from "@/assets/icons/map-search-bookmark.svg"; import { MobileRouteCardCarousel } from "@/app/(main)/map/_components/MobileRouteCardCarousel"; import { MobileRouteExpandedShell } from "@/app/(main)/map/_components/MobileRoutePlaceCard"; import { MobileRoutePlaceDetail } from "@/app/(main)/map/_components/MobileRoutePlaceDetail"; @@ -88,44 +86,41 @@ function SearchPlaceCard({ className="absolute inset-0 rounded-md focus-visible:outline-2 focus-visible:outline-primary" /> ) : null} -
    -
    -
    -

    +
    +
    +
    +

    {item.name}

    {item.category ? ( -

    +

    {item.category}

    ) : null} + {item.rating != null ? ( +
    + + + {item.rating.toFixed(1)} + + {item.userRatingCount != null ? ( + + ({item.userRatingCount.toLocaleString()}개) + + ) : null} +
    + ) : null}
    - {item.rating != null ? ( -
    - - - {item.rating.toFixed(1)} - - {item.userRatingCount != null ? ( - - ({item.userRatingCount.toLocaleString()}개) - - ) : null} -
    - ) : null} -
    +
    diff --git a/src/components/map/useMapPlaceSearch.test.tsx b/src/components/map/useMapPlaceSearch.test.tsx index a0e5cef1..87dc8692 100644 --- a/src/components/map/useMapPlaceSearch.test.tsx +++ b/src/components/map/useMapPlaceSearch.test.tsx @@ -141,6 +141,10 @@ it("mobile specific reuses chosen resolved detail without Text Search", async () expect(client.getQueryData(placeDetailQueryKey("first"))).toEqual(rawDetail); expect(usePlaceSearchStore.getState().items[0].rating).toBe(4.5); expect(usePlaceSearchStore.getState().expanded).toBe(false); + expect(network.select).toHaveBeenLastCalledWith( + expect.objectContaining({ googlePlaceId: "first", name: "추천" }), + expect.objectContaining({ analyticsSource: "search", itinerarySource: "search" }), + ); }); it("editing input suppresses a late response and analytics", async () => { let finish!: (v: unknown) => void; diff --git a/src/components/map/useMapPlaceSearch.ts b/src/components/map/useMapPlaceSearch.ts index 8d2d8f92..a8b45001 100644 --- a/src/components/map/useMapPlaceSearch.ts +++ b/src/components/map/useMapPlaceSearch.ts @@ -213,7 +213,7 @@ export function useMapPlaceSearch(mobile: boolean) { termination: result.termination, }); setActiveSearchMapPins(placeSearchResultsToMapPins(items)); - if (!mobile) { + if (!mobile || confirmation.kind === "specific") { const selected = result.anchor ? (items.find( (item) => item.googlePlaceId === result.anchor!.googlePlaceId, @@ -225,7 +225,7 @@ export function useMapPlaceSearch(mobile: boolean) { analyticsRankBucket: selected ? searchRankBuckets[selected.googlePlaceId] : undefined, - ...(result.anchor ? { skipMapRecenter: true } : {}), + ...(!mobile && result.anchor ? { skipMapRecenter: true } : {}), }); } if (confirmation.kind === "whole") { diff --git a/src/components/search/MapMobileSearchScreen.tsx b/src/components/search/MapMobileSearchScreen.tsx index f04a8367..49b7d4fe 100644 --- a/src/components/search/MapMobileSearchScreen.tsx +++ b/src/components/search/MapMobileSearchScreen.tsx @@ -3,7 +3,6 @@ import { useEffect, useEffectEvent, useState, type RefObject } from "react"; import { createPortal } from "react-dom"; import { - ChevronLeftIcon, CloseIcon, LocationIcon, SearchIcon, @@ -122,16 +121,7 @@ export function MapMobileSearchScreen({ !open && "hidden", )} > -
    - -
    +
    -
    {error ? (
    void; + onBack?: () => void; + backLabel?: string; }; type Suggestions = { query: string; @@ -45,6 +47,8 @@ export function MapPlaceSearchInput({ active: enabled = true, autoConfirm = false, onQueryChange, + onBack, + backLabel = "지도로 돌아가기", }: MapPlaceSearchInputProps) { const mapCenter = useMapCenterStore((s) => s.mapCenter); const [value, setValue] = useState(initialQuery); @@ -195,17 +199,22 @@ export function MapPlaceSearchInput({ ref={containerRef} className={cn( "relative", - mobile ? "w-full" : "w-[400px] max-w-[calc(100vw-32px)]", + mobile ? "flex min-h-0 w-full flex-col" : "w-[400px] max-w-[calc(100vw-32px)]", )} >
    { event.preventDefault(); void confirm(active); }} > -
    - +
    + {onBack ? ( + + ) : } {value ? ( @@ -291,14 +300,16 @@ export function MapPlaceSearchInput({ role="listbox" aria-label="검색 추천" className={cn( - "mt-2 overflow-hidden rounded-lg border border-border-subtle bg-fill-subtle", - !mobile && "absolute inset-x-0 top-full shadow-sm", + "bg-fill-subtle", + mobile + ? "min-h-0 overflow-y-auto overscroll-contain" + : "absolute inset-x-0 top-full mt-2 max-h-[calc(100dvh-96px)] overflow-y-auto rounded-lg border border-border-subtle", )} > void confirm(0)} /> {rows.map((prediction, index) => ( @@ -348,16 +359,16 @@ function SuggestionRow({ role="option" id={id} aria-selected={active} - className={cn("h-14", active && "bg-fill")} + className={cn("h-14 border-b-[0.5px] border-border-subtle last:border-b-0", active && "bg-fill")} > - ) : } + ) : } {value ? ( ) : null}
    From 2afc885d8a9c6fa5e84a5cdbec2b700bc747f75c Mon Sep 17 00:00:00 2001 From: minbros Date: Thu, 15 Oct 2026 11:01:01 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat(search):=20=EC=9B=B9=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B2=B0=EA=B3=BC=20=ED=95=80=EC=97=90=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EB=A7=90=ED=92=8D=EC=84=A0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전체 검색 후 자동 상세 표시를 제거하고 핀에서 미리보기 제공 - 화살표로 기존 상세 패널 연결 및 검색 순위 분석 유지 - 닫기 버튼 정렬과 사방 여백 통일, 모바일 흐름 유지 --- docs/analytics/amplitude-tracking-plan.md | 2 + src/app/globals.css | 9 ++ src/components/map/Map.search-rank.test.tsx | 89 +++++++++++++------ src/components/map/Map.tsx | 50 ++++++++++- src/components/map/SearchPlacePreview.tsx | 40 +++++++++ src/components/map/useMapPlaceSearch.test.tsx | 43 +++------ src/components/map/useMapPlaceSearch.ts | 42 ++------- src/stores/place-search-store.ts | 2 +- 8 files changed, 180 insertions(+), 97 deletions(-) create mode 100644 src/components/map/SearchPlacePreview.tsx diff --git a/docs/analytics/amplitude-tracking-plan.md b/docs/analytics/amplitude-tracking-plan.md index 00529350..5638293f 100644 --- a/docs/analytics/amplitude-tracking-plan.md +++ b/docs/analytics/amplitude-tracking-plan.md @@ -53,6 +53,8 @@ | `packing_item_checked` | 본인 체크 요청 응답으로 미완료 → 완료가 확정됐을 때 | `room_id` (Amplitude만) | 없음 | Amplitude | | `packing_item_unchecked` | 본인 체크 요청 응답으로 완료 → 미완료가 확정됐을 때 | `room_id` (Amplitude만) | 없음 | Amplitude | +웹 전체 검색은 결과 핀만 표시하며, 핀의 Text Search 미리보기 말풍선은 `view_place`로 세지 않는다. 말풍선의 화살표를 눌러 기존 상세 패널의 데이터가 표시될 때 `view_place`를 기록하고 원래 검색 결과 순서의 `rank_bucket`을 유지한다. 모바일 결과 카드·상세 흐름과 자동완성 특정 장소 선택은 기존 기준을 유지한다. + 준비물 체크는 상태가 실제로 바뀐 경우만 기록한다. 자동 목록 초기화, 읽기, 낙관적 변경, 실패·불확실한 응답, 중복·차단 요청은 포함하지 않는다. 가계부/준비물 쓰기는 성공 응답 뒤 후속 조회가 실패해도 집계하되, 계정·방·권한이 바뀐 뒤의 응답을 다른 사용자에게 귀속하지 않는다. 브로드캐스트 수신자의 이벤트는 만들지 않는다. 정산 요약 조회는 버튼 클릭이나 로딩·오류가 아니라 성공 데이터 표시를 뜻한다. 열린 동안 재렌더·재조회로 중복하지 않으며 닫았다 다시 열면 새 조회다. 송금·정산 완료 이벤트는 구현하지 않는다. `chat_message_sent`도 서버 저장·AI 응답 성공으로 해석하지 않는다. diff --git a/src/app/globals.css b/src/app/globals.css index c9a88420..7abd1556 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -13,6 +13,15 @@ */ @custom-variant mobile (&:where(.is-mobile-device, .is-mobile-device *)); +/* 검색 말풍선은 Google 기본 여백·스크롤바 대신 본문에서 사방 12px을 확보한다. */ +.gm-style-iw-c:has([data-search-place-preview]) { + padding: 0 !important; +} + +.gm-style-iw-d:has([data-search-place-preview]) { + overflow: auto !important; +} + @layer base { html { @apply font-sans; diff --git a/src/components/map/Map.search-rank.test.tsx b/src/components/map/Map.search-rank.test.tsx index 0ab3f0e5..791649d9 100644 --- a/src/components/map/Map.search-rank.test.tsx +++ b/src/components/map/Map.search-rank.test.tsx @@ -52,6 +52,9 @@ vi.mock("@/lib/analytics/track", () => ({ trackAnalyticsEvent: boundary.track, })); vi.mock("@vis.gl/react-google-maps", () => ({ + InfoWindow: ({ children }: { children: ReactNode }) => ( +
    {children}
    + ), useMap: () => null, useMapsLibrary: () => null, Map: ({ @@ -207,7 +210,38 @@ function expectPrivateFieldsAbsent(properties: Record) { ]) expect(properties).not.toHaveProperty(field); } -it("web prepended absent anchor remains unranked while original results keep third/fourth/eleventh buckets", async () => { +it("web preview closes independently and switching pins never fetches or opens detail", async () => { + await render(false); + boundary.page.mockResolvedValue({ items, nextPageToken: null }); + await act(async () => { await controller.confirm({ query: "원문", kind: "whole", generation: 0, session: null }); }); + const pin = (name: string) => [...host.querySelectorAll("[data-marker]")].find(b => b.textContent === name)!; + const calls = vi.mocked(apiFetch).mock.calls.length; + await act(async () => pin("결과3").click()); + expect(host.querySelector('[role="dialog"]')?.textContent).toContain("결과3"); + await act(async () => pin("결과4").click()); + expect(host.querySelectorAll('[role="dialog"]')).toHaveLength(1); + expect(host.querySelector('[role="dialog"]')?.textContent).toContain("결과4"); + expect(views()).toHaveLength(0); + expect(apiFetch).toHaveBeenCalledTimes(calls); + await act(async () => host.querySelector('[aria-label="미리보기 닫기"]')!.click()); + expect(host.querySelector('[role="dialog"]')).toBeNull(); + expect(usePlaceSearchStore.getState().items).toHaveLength(11); + await act(async () => pin("결과4").click()); + expect(host.querySelector('[role="dialog"]')?.textContent).toContain("결과4"); +}); +it("web specific autocomplete selection retains direct detail behavior when its pin is reopened", async () => { + await render(false); + await act(async () => { await controller.confirm({ query: "원문", kind: "specific", generation: 0, + session: { sessionToken: "token", expiresAt: Date.now() + 60000 }, prediction: prediction("result-4") }); }); + expect(views()).toHaveLength(1); + await act(async () => host.querySelector('[aria-label="닫기"]')!.click()); + const marker = [...host.querySelectorAll("[data-marker]")].find(b => b.textContent === "결과4")!; + await act(async () => marker.click()); + expect(host.querySelector('[aria-label="검색 장소 미리보기"]')).toBeNull(); + expect(views()).toHaveLength(2); + expect(views().at(-1)![1].rank_bucket).toBeUndefined(); +}); +it("web pins preview Text Search data without fetching detail; arrow opens detail with original rank", async () => { await render(false); boundary.page.mockResolvedValue({ items, nextPageToken: null }); await act(async () => { @@ -219,8 +253,8 @@ it("web prepended absent anchor remains unranked while original results keep thi prediction: prediction(), }); }); - expect(usePlaceSearchStore.getState().items[0].googlePlaceId).toBe("anchor"); - expect(views().at(-1)![1].rank_bucket).toBeUndefined(); + expect(usePlaceSearchStore.getState().items[0].googlePlaceId).toBe("result-1"); + expect(views()).toHaveLength(0); for (const [number, rank] of [ [3, "1_3"], [4, "4_10"], @@ -229,7 +263,14 @@ it("web prepended absent anchor remains unranked while original results keep thi const marker = [ ...host.querySelectorAll("[data-marker]"), ].find((button) => button.textContent === `결과${number}`)!; + const viewCount = views().length; + const requestCount = vi.mocked(apiFetch).mock.calls.length; await act(async () => marker.click()); + expect(views()).toHaveLength(viewCount); + expect(apiFetch).toHaveBeenCalledTimes(requestCount); + expect(host.querySelector('[role="dialog"]')?.textContent).toContain(`결과${number}`); + expect(host.querySelector('[role="dialog"]')?.textContent).toContain("4.3"); + await act(async () => host.querySelector(`[aria-label="결과${number} 상세정보 보기"]`)!.click()); expect(views().at(-1)![1]).toMatchObject({ interaction_source: "search", rank_bucket: rank, @@ -237,7 +278,7 @@ it("web prepended absent anchor remains unranked while original results keep thi expectPrivateFieldsAbsent(views().at(-1)![1]); } }); -it("web first-place view waits for Text Search membership and keeps its original eleventh rank", async () => { +it("web resolved recommendation never auto-opens even when it is a Text Search result", async () => { await render(false); let finish!: (page: unknown) => void; boundary.page.mockReturnValue( @@ -261,14 +302,10 @@ it("web first-place view waits for Text Search membership and keeps its original finish({ items, nextPageToken: null }); await pending; }); - expect(views()).toHaveLength(1); - expect(views()[0][1]).toMatchObject({ - rank_bucket: "11_plus", - interaction_source: "search", - }); - expectPrivateFieldsAbsent(views()[0][1]); + expect(views()).toHaveLength(0); + expect(usePlaceSearchStore.getState().selectedId).toBeNull(); }); -it("web map-center fallback auto-selection retains the first result bucket", async () => { +it("web map-center fallback shows only pins without auto-selection", async () => { await render(false); boundary.page.mockResolvedValue({ items, nextPageToken: null }); await act(async () => { @@ -279,8 +316,8 @@ it("web map-center fallback auto-selection retains the first result bucket", asy session: { sessionToken: "token", expiresAt: Date.now()+60000 }, }); }); - expect(views()).toHaveLength(1); - expect(views()[0][1].rank_bucket).toBe("1_3"); + expect(views()).toHaveLength(0); + expect(usePlaceSearchStore.getState().selectedId).toBeNull(); }); it("mobile expanded result emits original fourth/eleventh rank while specific prediction stays unranked", async () => { await render(true); @@ -329,7 +366,7 @@ it("mobile expanded result emits original fourth/eleventh rank while specific pr expect(boundary.page).toHaveBeenCalledTimes(1); }); -it("failed Text Search settles the already visible recommendation as one unranked view", async () => { +it("failed Text Search does not expose its anchor or emit a detail view", async () => { await render(false); boundary.page.mockRejectedValue(new Error("검색 실패")); await act(async () => { @@ -341,12 +378,10 @@ it("failed Text Search settles the already visible recommendation as one unranke prediction: prediction(), }); }); - expect(views()).toHaveLength(1); - expect(views()[0][1].rank_bucket).toBeUndefined(); - expectPrivateFieldsAbsent(views()[0][1]); + expect(views()).toHaveLength(0); }); -it("cancelling rank lookup keeps complete visible data and cannot add late rank/view events", async () => { +it("cancelling pending search cannot add late pins or detail views", async () => { await render(false); let finish!: (page: unknown) => void; boundary.page.mockReturnValue( @@ -367,13 +402,12 @@ it("cancelling rank lookup keeps complete visible data and cannot add late rank/ await vi.waitFor(() => expect(boundary.page).toHaveBeenCalled()); expect(views()).toHaveLength(0); await act(async () => controller.invalidate(false)); - expect(views()).toHaveLength(1); - expect(views()[0][1].rank_bucket).toBeUndefined(); + expect(views()).toHaveLength(0); await act(async () => { finish({ items, nextPageToken: null }); await pending; }); - expect(views()).toHaveLength(1); + expect(views()).toHaveLength(0); expect(usePlaceSearchStore.getState().items).toEqual([]); }); @@ -392,6 +426,7 @@ it("ordinary POI selection does not inherit a previous Text Search rank", async ...host.querySelectorAll("[data-marker]"), ].find((button) => button.textContent === "결과4")!; await act(async () => fourth.click()); + await act(async () => host.querySelector('[aria-label="결과4 상세정보 보기"]')!.click()); expect(views().at(-1)![1].rank_bucket).toBe("4_10"); await act(async () => host.querySelector("[data-general-poi]")!.click(), @@ -401,7 +436,7 @@ it("ordinary POI selection does not inherit a previous Text Search rank", async expectPrivateFieldsAbsent(views().at(-1)![1]); }); -it("discover ownership settles still-visible complete recommendation once without late rank/view", async () => { +it("discover ownership cancels pending search without showing its anchor", async () => { await render(false); let finish!: (page: unknown) => void; boundary.page.mockReturnValue( @@ -421,17 +456,15 @@ it("discover ownership settles still-visible complete recommendation once withou }); await vi.waitFor(() => expect(boundary.page).toHaveBeenCalled()); expect(views()).toHaveLength(0); - expect(host.textContent).toContain("결과1"); + expect(host.textContent).not.toContain("결과1"); await act(async () => useMapPinsFocusStore.getState().claimFocus("discover")); - expect(views()).toHaveLength(1); - expect(views()[0][1].rank_bucket).toBeUndefined(); - expectPrivateFieldsAbsent(views()[0][1]); - expect(host.textContent).toContain("결과1"); + expect(views()).toHaveLength(0); + expect(host.textContent).not.toContain("결과1"); await act(async () => { finish({ items, nextPageToken: null }); await pending; }); - expect(views()).toHaveLength(1); + expect(views()).toHaveLength(0); expect(usePlaceSearchStore.getState().items).toEqual([]); expect( boundary.track.mock.calls.filter( diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index caec2701..bc633a99 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -5,6 +5,7 @@ import { usePathname } from "next/navigation"; import { Bookmark } from "lucide-react"; import { AdvancedMarker, + InfoWindow, Map as GoogleMap, useMap, useMapsLibrary, @@ -54,6 +55,7 @@ import { mapPinBodyBorderProps, } from "./map-pin-stroke"; import { MapSearchHereButton } from "./MapSearchHereButton"; +import { SearchPlacePreview } from "./SearchPlacePreview"; import { MapDiscoverToolbar } from "./MapDiscoverToolbar"; import { RouteViewCameraController } from "./RouteViewCameraController"; import { MapDiscoverPlaces } from "./MapDiscoverPlaces"; @@ -292,14 +294,38 @@ function releaseSearchForMapSelection() { useSearchRecenterStore.getState().clearSearchSnapshot(); } +const SEARCH_PREVIEW_OFFSET: [number, number] = [0, -40]; + function MapSearchResultPins() { + const map = useMap(); const pins = useSearchMapPinsStore((s) => s.pins); const pinsFocus = useMapPinsFocusStore((s) => s.focus); const { selectedPlace, setSelectedPlace } = useSelectedPlace(); const { isMobileDevice } = useMobileView(); const searchSelectedId = usePlaceSearchStore((s) => s.selectedId); + const items = usePlaceSearchStore((s) => s.items); + const status = usePlaceSearchStore((s) => s.status); + const kind = usePlaceSearchStore((s) => s.confirmationKind); + const fittedItemsRef = useRef(null); + const preview = !isMobileDevice && kind === "whole" && !selectedPlace + ? items.find((item) => item.googlePlaceId === searchSelectedId) + : undefined; const selectedPlaceId = isMobileDevice && !selectedPlace ? searchSelectedId ?? "" : selectedPlace?.googlePlaceId?.trim() ?? ""; + // 전체 검색 결과가 준비되면 첫 추천 대신 실제 결과들이 보이도록 맞춘다. + useEffect(() => { + if (!map || isMobileDevice || pinsFocus !== "search" || kind !== "whole" || status !== "done" || !items.length || usePlaceSearchStore.getState().selectedId || fittedItemsRef.current === items) return; + fittedItemsRef.current = items; + if (items.length === 1) { + panToWithinWorld(map, items[0].location); + map.setZoom(16); + } else { + const bounds = new google.maps.LatLngBounds(); + items.forEach((item) => bounds.extend(item.location)); + map.fitBounds(bounds, 80); + } + }, [map, isMobileDevice, pinsFocus, kind, status, items]); + if (pinsFocus !== "search") return null; return ( @@ -315,8 +341,11 @@ function MapSearchResultPins() { const result = usePlaceSearchStore.getState().items.find((item) => item.googlePlaceId === pin.googlePlaceId); if (result) { usePlaceSearchStore.getState().select(result.googlePlaceId); - if (isMobileDevice) { setSelectedPlace(null); return; } - setSelectedPlace(result, { itinerarySource: "search", analyticsSource: "search", analyticsRankBucket: usePlaceSearchStore.getState().searchRankBuckets[result.googlePlaceId], preserveMapZoom: true }); + if (!isMobileDevice && kind === "specific") { + setSelectedPlace(result, { itinerarySource: "search", analyticsSource: "search", preserveMapZoom: true }); + } else { + setSelectedPlace(null); + } return; } releaseSearchForMapSelection(); @@ -335,6 +364,22 @@ function MapSearchResultPins() { ))} + {preview ? ( + { + if (usePlaceSearchStore.getState().selectedId === preview.googlePlaceId) + usePlaceSearchStore.getState().select(null); + }}> + usePlaceSearchStore.getState().select(null)} onOpenDetail={() => { + setSelectedPlace(preview, { + itinerarySource: "search", analyticsSource: "search", + analyticsRankBucket: usePlaceSearchStore.getState().searchRankBuckets[preview.googlePlaceId], + preserveMapZoom: true, + }); + }} /> + + ) : null} ); } @@ -483,6 +528,7 @@ export default function TripMap({ } // 빈 곳은 완료된 검색 핀·카드를 유지한다. 진행 중인 응답만 무효화하고 상세·일정 강조를 닫는다. if (usePlaceSearchStore.getState().status === "pending") usePlaceSearchStore.getState().invalidate(); + if (!isMobileDevice) usePlaceSearchStore.getState().select(null); setSelectedPlace(null); clearPlanFocus(); }; diff --git a/src/components/map/SearchPlacePreview.tsx b/src/components/map/SearchPlacePreview.tsx new file mode 100644 index 00000000..d4e2537f --- /dev/null +++ b/src/components/map/SearchPlacePreview.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { ArrowRight, Star, X } from "lucide-react"; +import type { PlaceSearchResult } from "@/hooks/usePlacesSearch"; + +/** Text Search 응답만 표시한다. 상세 조회는 화살표를 누른 뒤 기존 패널이 담당한다. */ +export function SearchPlacePreview({ place, onOpenDetail, onClose }: { + place: PlaceSearchResult; + onOpenDetail: () => void; + onClose: () => void; +}) { + return ( +
    +
    +

    {place.name}

    + +
    + {place.category ?

    {place.category}

    : null} + {place.rating != null ? ( +
    + + {place.rating.toFixed(1)} + {place.userRatingCount != null ? ({place.userRatingCount.toLocaleString()}개) : null} +
    + ) : null} +
    + + {place.isOpen == null ? "" : place.isOpen ? "영업 중" : "영업 종료"} + + +
    +
    + ); +} diff --git a/src/components/map/useMapPlaceSearch.test.tsx b/src/components/map/useMapPlaceSearch.test.tsx index 87dc8692..79919e0a 100644 --- a/src/components/map/useMapPlaceSearch.test.tsx +++ b/src/components/map/useMapPlaceSearch.test.tsx @@ -103,21 +103,13 @@ afterEach(async () => { await act(async () => root.unmount()); host.remove(); }); -it("web retains first prediction selection absent from text results, preserving result metadata", async () => { - await act(async () => { - await api.confirm(confirmation()); - }); - expect(network.select).toHaveBeenCalledWith( - expect.objectContaining({ - googlePlaceId: "first", - location: { lat: 37, lng: 127 }, - }), - expect.objectContaining({ analyticsSource: "search" }), - ); - expect( - usePlaceSearchStore.getState().items.map((x) => x.googlePlaceId), - ).toEqual(["first", "other"]); - expect(usePlaceSearchStore.getState().items[1].rating).toBe(4.2); +it("web whole search publishes only Text Search pins without selecting a detail or recommendation", async () => { + await act(async () => { await api.confirm(confirmation()); }); + expect(network.select).toHaveBeenLastCalledWith(null); + expect(usePlaceSearchStore.getState().selectedId).toBeNull(); + expect(usePlaceSearchStore.getState().items.map(x => x.googlePlaceId)).toEqual(["other"]); + expect(usePlaceSearchStore.getState().items[0].rating).toBe(4.2); + expect(client.getQueryData(placeDetailQueryKey("first"))).toEqual(rawDetail); }); it("mobile whole search keeps cards collapsed and reuses resolved full detail without GET supplement", async () => { await act(async () => root.render()); @@ -231,25 +223,16 @@ it.each(["room", "account"])( }, ); -it("editing while Text Search is pending retains already resolved complete selection", async () => { +it("editing during Text Search never exposes the resolved anchor or late results", async () => { let finish!: (value: unknown) => void; - network.page.mockReturnValue( - new Promise((resolve) => { - finish = resolve; - }), - ); + network.page.mockReturnValue(new Promise(resolve => { finish = resolve; })); const pending = api.confirm(confirmation()); await vi.waitFor(() => expect(network.page).toHaveBeenCalled()); - expect(network.select).toHaveBeenLastCalledWith( - expect.objectContaining({ googlePlaceId: "first" }), - expect.any(Object), - ); + expect(network.select).toHaveBeenLastCalledWith(null); await act(async () => api.invalidate(false)); - expect(network.select).toHaveBeenLastCalledWith(expect.objectContaining({ googlePlaceId: "first" }), expect.any(Object)); - await act(async () => { - finish({ items: [], nextPageToken: null }); - await pending; - }); + await act(async () => { finish({ items: [], nextPageToken: null }); await pending; }); + expect(network.select).toHaveBeenLastCalledWith(null); + expect(usePlaceSearchStore.getState().items).toEqual([]); }); it("scope loss during resolve cannot seed cache or display stale detail", async () => { diff --git a/src/components/map/useMapPlaceSearch.ts b/src/components/map/useMapPlaceSearch.ts index a8b45001..34f05524 100644 --- a/src/components/map/useMapPlaceSearch.ts +++ b/src/components/map/useMapPlaceSearch.ts @@ -115,8 +115,9 @@ export function useMapPlaceSearch(mobile: boolean) { termination: "not-requested", resolution: null, }); + setSelectedPlace(null); + if (!mobile) usePlaceSearchStore.setState({ selectedId: null }); if (mobile) { - setSelectedPlace(null); useMobileMapSearchStore.setState({ textQuery: query }); } const toAnchor = (anchor: ResolvedSearchAnchor): PlaceSearchResult => ({ @@ -132,7 +133,6 @@ export function useMapPlaceSearch(mobile: boolean) { isOpen: anchor.detail.regularOpeningHours?.openNow ?? null, location: anchor.location, }); - let anchoredSelection: PlaceSearchResult | null = null; try { const result = await confirmPlaceSearch({ ...confirmation, @@ -149,20 +149,6 @@ export function useMapPlaceSearch(mobile: boolean) { }, }); }, - onAnchor: (anchor) => { - usePlaceSearchStore.setState({ - termination: anchor.termination, - ...(!mobile ? { selectedId: anchor.googlePlaceId } : {}), - }); - if (!mobile) { - anchoredSelection = toAnchor(anchor); - setSelectedPlace(anchoredSelection, { - itinerarySource: "search", - analyticsSource: "search", - analyticsRankPending: confirmation.kind === "whole", - }); - } - }, search: async (params) => { useSearchRecenterStore.getState().setSearchSnapshot({ center: { lat: params.latitude, lng: params.longitude }, @@ -191,19 +177,10 @@ export function useMapPlaceSearch(mobile: boolean) { if (confirmation.kind === "specific" && result.anchor) { items = [toAnchor(result.anchor)]; useSearchRecenterStore.getState().clearSearchSnapshot(); - } else if ( - !mobile && - result.anchor && - !items.some( - (item) => item.googlePlaceId === result.anchor!.googlePlaceId, - ) - ) { - items = [toAnchor(result.anchor), ...items]; } - const selectedId = - !mobile && result.anchor - ? result.anchor.googlePlaceId - : (items[0]?.googlePlaceId ?? null); + const selectedId = mobile || confirmation.kind === "specific" + ? (items[0]?.googlePlaceId ?? null) + : null; usePlaceSearchStore.setState({ items, searchRankBuckets, @@ -213,7 +190,7 @@ export function useMapPlaceSearch(mobile: boolean) { termination: result.termination, }); setActiveSearchMapPins(placeSearchResultsToMapPins(items)); - if (!mobile || confirmation.kind === "specific") { + if (confirmation.kind === "specific") { const selected = result.anchor ? (items.find( (item) => item.googlePlaceId === result.anchor!.googlePlaceId, @@ -225,7 +202,6 @@ export function useMapPlaceSearch(mobile: boolean) { analyticsRankBucket: selected ? searchRankBuckets[selected.googlePlaceId] : undefined, - ...(!mobile && result.anchor ? { skipMapRecenter: true } : {}), }); } if (confirmation.kind === "whole") { @@ -238,12 +214,6 @@ export function useMapPlaceSearch(mobile: boolean) { return true; } catch (error) { if (!isCurrent()) return false; - if (anchoredSelection) - setSelectedPlace(anchoredSelection, { - itinerarySource: "search", - analyticsSource: "search", - skipMapRecenter: true, - }); usePlaceSearchStore.setState({ status: "error", termination: diff --git a/src/stores/place-search-store.ts b/src/stores/place-search-store.ts index e6423035..96126b1a 100644 --- a/src/stores/place-search-store.ts +++ b/src/stores/place-search-store.ts @@ -20,7 +20,7 @@ type PlaceSearchState = { resolution: Omit | null; setScope: (scope: string) => void; invalidate: () => number; - select: (id: string) => void; + select: (id: string | null) => void; setExpanded: (expanded: boolean) => void; clear: () => void; }; From 8d9c493306cd6c9f2d33c009214c31d53e5a637a Mon Sep 17 00:00:00 2001 From: minbros Date: Thu, 15 Oct 2026 11:09:30 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix(search):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EA=B3=BC=20=EC=9E=90=EB=8F=99=EC=99=84?= =?UTF-8?q?=EC=84=B1=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EC=97=AC=EB=B0=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/BookmarkFolderDetailView.tsx | 3 +- src/app/globals.css | 9 +----- src/components/map/MapPlaceSearch.tsx | 2 +- .../map/MobileSearchResultCarousel.tsx | 8 ++--- src/components/map/SearchPlacePreview.tsx | 4 +-- src/components/place/PlaceDetailPanel.tsx | 3 +- .../search/MapPlaceSearchInput.test.tsx | 21 +++++++++++++ src/components/search/MapPlaceSearchInput.tsx | 30 +++++++++++++------ src/lib/layout/scrollbar-gutter.test.ts | 8 +---- 9 files changed, 55 insertions(+), 33 deletions(-) diff --git a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx index 28855517..1bc73da3 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx @@ -13,8 +13,9 @@ import { BookmarkFolderDetailHeader } from "./BookmarkFolderDetailHeader"; import { BookmarkListSkeleton } from "./BookmarkListSkeleton"; import { BookmarkPlaceRow } from "./BookmarkPlaceRow"; +// 장소 추가·삭제 시 데스크톱 목록 행의 너비가 흔들리지 않게 한다. const SCROLLBAR = - "min-h-0 flex-1 overflow-y-auto [scrollbar-color:rgba(0,0,0,0.2)_transparent]"; + "min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable] mobile:[scrollbar-gutter:auto] [scrollbar-color:rgba(0,0,0,0.2)_transparent]"; function trimGooglePlaceId(raw: unknown): string { return typeof raw === "string" ? raw.trim() : ""; diff --git a/src/app/globals.css b/src/app/globals.css index 7abd1556..ca82d4cb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -27,14 +27,7 @@ @apply font-sans; } - /** - * 세로 스크롤 컨테이너가 넘치기 전부터 scrollbar 공간을 확보해 - * 스크롤 활성화 시 내부 버튼·카드 너비가 변하지 않게 한다. - * textarea는 입력 영역이 상시 좁아지는 것을 피하기 위해 제외한다. - */ - :where(.overflow-y-auto:not(textarea)) { - scrollbar-gutter: stable; - } + /* 스크롤바 공간 예약은 폭 유지가 필요한 본문·목록에서만 개별 지정한다. */ } /* 모바일 하단 탭바(메뉴 52px + 기기 safe area)가 있을 때만 토스트를 그 위로 올림 */ diff --git a/src/components/map/MapPlaceSearch.tsx b/src/components/map/MapPlaceSearch.tsx index bcf313ef..5111df3d 100644 --- a/src/components/map/MapPlaceSearch.tsx +++ b/src/components/map/MapPlaceSearch.tsx @@ -38,7 +38,7 @@ export function MapPlaceSearch({ mobile }: { mobile: boolean }) { {error} @@ -136,7 +136,7 @@ function SearchPlaceCard({ }); toast.success("장소를 채팅으로 보냈어요"); }} - className="pointer-events-auto flex h-8 min-w-0 flex-1 items-center justify-center gap-[5px] rounded-[7px] border border-border text-[12px] font-semibold text-primary disabled:opacity-50" + className="pointer-events-auto flex h-8 cursor-pointer hover:brightness-95 disabled:cursor-not-allowed min-w-0 flex-1 items-center justify-center gap-[5px] rounded-[7px] border border-border text-[12px] font-semibold text-primary disabled:opacity-50" > 채팅으로 보내기 @@ -145,7 +145,7 @@ function SearchPlaceCard({ type="button" aria-label={`${item.name} 일정에 추가`} onClick={() => setModal("schedule")} - className="pointer-events-auto flex h-8 min-w-0 flex-1 items-center justify-center gap-[5px] rounded-[7px] bg-primary text-[12px] font-semibold text-text-inverse" + className="pointer-events-auto flex h-8 cursor-pointer hover:brightness-95 disabled:cursor-not-allowed min-w-0 flex-1 items-center justify-center gap-[5px] rounded-[7px] bg-primary text-[12px] font-semibold text-text-inverse" > 일정에 추가 diff --git a/src/components/map/SearchPlacePreview.tsx b/src/components/map/SearchPlacePreview.tsx index d4e2537f..c04f625e 100644 --- a/src/components/map/SearchPlacePreview.tsx +++ b/src/components/map/SearchPlacePreview.tsx @@ -14,7 +14,7 @@ export function SearchPlacePreview({ place, onOpenDetail, onClose }: {

    {place.name}

    @@ -31,7 +31,7 @@ export function SearchPlacePreview({ place, onOpenDetail, onClose }: { {place.isOpen == null ? "" : place.isOpen ? "영업 중" : "영업 종료"}
    diff --git a/src/components/place/PlaceDetailPanel.tsx b/src/components/place/PlaceDetailPanel.tsx index 1699bebe..bf11c8e7 100644 --- a/src/components/place/PlaceDetailPanel.tsx +++ b/src/components/place/PlaceDetailPanel.tsx @@ -41,8 +41,9 @@ type PlaceDetailPanelProps = SearchResultCardProps & { const SHEET_PHOTO_HEIGHT_PX = 200; const SHEET_PHOTO_HIDDEN_RATIO = 3 / 5; +// 홈·리뷰 전환으로 스크롤바가 생겨도 상세 본문과 버튼의 너비를 유지한다. const SCROLL_AREA_CLASS = - "min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-color:rgba(0,0,0,0.15)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-black/[0.15] [&::-webkit-scrollbar-track]:bg-transparent"; + "min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-color:rgba(0,0,0,0.15)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-black/[0.15] [&::-webkit-scrollbar-track]:bg-transparent"; export function PlaceDetailPanel({ name, diff --git a/src/components/search/MapPlaceSearchInput.test.tsx b/src/components/search/MapPlaceSearchInput.test.tsx index cc808e60..b4487477 100644 --- a/src/components/search/MapPlaceSearchInput.test.tsx +++ b/src/components/search/MapPlaceSearchInput.test.tsx @@ -171,3 +171,24 @@ it("nullable structured main text falls back to actual complete prediction text" await type("원래"); await wait(); expect(host.querySelectorAll('[role="option"]')[1]?.textContent).toContain("전체 실제 이름"); }); + +it.each(["whole", "specific"])("clears successful desktop %s input without invalidating published results", async kind => { + await type("원래"); await wait(); + const invalidations = invalidate.mock.calls.length; + if (kind === "specific") { await key("ArrowDown"); await key("ArrowDown"); } + await key("Enter"); + expect(host.querySelector("input")!.value).toBe(""); + expect(host.querySelector('[role="listbox"]')).toBeNull(); + expect(invalidate).toHaveBeenCalledTimes(invalidations); +}); +it("retains failed input for retry and never clears a newer draft after a late success", async () => { + confirm.mockResolvedValueOnce(false); + await type("실패 검색"); await wait(); await key("Enter"); + expect(host.querySelector("input")!.value).toBe("실패 검색"); + let finish!: (success: boolean) => void; + confirm.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); + await type("진행 검색"); await wait(); await key("Enter"); + await type("새 입력"); + await act(async () => finish(true)); + expect(host.querySelector("input")!.value).toBe("새 입력"); +}); diff --git a/src/components/search/MapPlaceSearchInput.tsx b/src/components/search/MapPlaceSearchInput.tsx index df156407..6a59ee39 100644 --- a/src/components/search/MapPlaceSearchInput.tsx +++ b/src/components/search/MapPlaceSearchInput.tsx @@ -165,13 +165,21 @@ export function MapPlaceSearchInput({ setOpen(false); fieldRef.current?.blur(); try { - await onConfirm({ + const success = await onConfirm({ query: value.trim(), kind: index > 0 ? "specific" : "whole", prediction, session: requestSession, generation: requestGeneration, }); + if (success && !mobile && generation.current === requestGeneration) { + // 입력창만 비운다. update("")는 이미 표시한 검색 결과까지 지우므로 호출하지 않는다. + setValue(""); + setSuggestions(null); + setActive(-1); + autoQuery.current = null; + onQueryChange?.(""); + } } finally { if (generation.current === requestGeneration) { setPending(false); @@ -214,7 +222,7 @@ export function MapPlaceSearchInput({ mobile ? "h-12 gap-2 rounded-lg pl-3.5 pr-2.5" : "h-9 gap-1.5 rounded-md px-2.5", )}> {onBack ? ( - ) : } @@ -293,7 +301,7 @@ export function MapPlaceSearchInput({ update(""); fieldRef.current?.focus(); }} - className={cn("flex h-full shrink-0 items-center justify-center", mobile ? "w-8" : "w-6")} + className={cn("flex h-full shrink-0 cursor-pointer items-center justify-center rounded hover:bg-fill focus-visible:outline-2 focus-visible:outline-primary", mobile ? "w-8" : "w-6")} > @@ -309,12 +317,13 @@ export function MapPlaceSearchInput({ "bg-fill-subtle", mobile ? "min-h-0 overflow-y-auto overscroll-contain" - : "absolute inset-x-0 top-full mt-2 max-h-[calc(100dvh-96px)] overflow-y-auto rounded-lg border border-border-subtle", + : "absolute inset-x-0 top-full mt-1.5 max-h-[calc(100dvh-96px)] overflow-y-auto rounded-md border border-border-subtle", )} > void confirm(0)} /> @@ -323,6 +332,7 @@ export function MapPlaceSearchInput({ key={prediction.placeId} id={`${listId}-${index + 1}`} active={active === index + 1} + compact={!mobile} title={prediction.structuredFormat?.mainText?.text != null ? prediction.structuredFormat.mainText : prediction.text} @@ -351,6 +361,7 @@ function SuggestionRow({ active, onClick, place = false, + compact = false, }: { id: string; title: string | PredictionText | null; @@ -358,6 +369,7 @@ function SuggestionRow({ active: boolean; onClick: () => void; place?: boolean; + compact?: boolean; }) { const Icon = place ? LocationIcon : SearchIcon; return ( @@ -365,21 +377,21 @@ function SuggestionRow({ role="option" id={id} aria-selected={active} - className={cn("h-14 border-b-[0.5px] border-border-subtle last:border-b-0", active && "bg-fill")} + className={cn("border-b-[0.5px] border-border-subtle last:border-b-0", compact ? (place ? "h-11" : "h-9") : "h-14", active && "bg-fill")} > +
    + ); + if (showSkeleton) return ( + + + + + ); + return ( + // 장소 상세 시트와 같은 홈·리뷰 탭 — 같은 상세 응답을 쓰므로 추가 호출이 없다 +
    + +
    + ); + } + return (
    @@ -58,38 +95,7 @@ export function MobileRoutePlaceDetail({ )}
    - {isError && !detail && active ? ( -
    -

    장소 정보를 불러오지 못했어요.

    - -
    - ) : showSkeleton ? ( - - - - - ) : ( - // 장소 상세 시트와 같은 홈·리뷰 탭 — 같은 상세 응답을 쓰므로 추가 호출이 없다 -
    - -
    - )} + {renderDetail()}
    ); diff --git a/src/app/(main)/search/page.tsx b/src/app/(main)/search/page.tsx index b52e4cab..ae28d0f7 100644 --- a/src/app/(main)/search/page.tsx +++ b/src/app/(main)/search/page.tsx @@ -10,7 +10,9 @@ export default function SearchPage() { const params = useSearchParams(); const { isMobileDevice } = useMobileView(); const query = params.get("q")?.trim() ?? ""; - const target = `${isMobileDevice ? "/map" : "/plan"}${query ? `?q=${encodeURIComponent(query)}` : ""}`; + const pathname = isMobileDevice ? "/map" : "/plan"; + const search = query ? `?q=${encodeURIComponent(query)}` : ""; + const target = `${pathname}${search}`; useEffect(() => { router.replace(target); }, [router, target]); diff --git a/src/app/globals.css b/src/app/globals.css index ca82d4cb..a676dc5a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -15,11 +15,11 @@ /* 검색 말풍선은 Google 기본 여백·스크롤바 대신 본문에서 사방 12px을 확보한다. */ .gm-style-iw-c:has([data-search-place-preview]) { - padding: 0 !important; + padding: 0 !important; /* NOSONAR: S9423 — Google Maps가 주입하는 인라인 padding을 검색 말풍선에서만 재정의한다. */ } .gm-style-iw-d:has([data-search-place-preview]) { - overflow: auto !important; + overflow: auto !important; /* NOSONAR: S9423 — Google Maps의 인라인 overflow:scroll에 의한 불필요한 스크롤바를 제거한다. */ } @layer base { diff --git a/src/components/map/Map.search-rank.test.tsx b/src/components/map/Map.search-rank.test.tsx index 791649d9..be885577 100644 --- a/src/components/map/Map.search-rank.test.tsx +++ b/src/components/map/Map.search-rank.test.tsx @@ -401,7 +401,7 @@ it("cancelling pending search cannot add late pins or detail views", async () => }); await vi.waitFor(() => expect(boundary.page).toHaveBeenCalled()); expect(views()).toHaveLength(0); - await act(async () => controller.invalidate(false)); + await act(async () => controller.invalidate()); expect(views()).toHaveLength(0); await act(async () => { finish({ items, nextPageToken: null }); diff --git a/src/components/map/MapPlaceSearch.server-session.test.tsx b/src/components/map/MapPlaceSearch.server-session.test.tsx index ffebce14..43b949f7 100644 --- a/src/components/map/MapPlaceSearch.server-session.test.tsx +++ b/src/components/map/MapPlaceSearch.server-session.test.tsx @@ -55,7 +55,7 @@ it.each(["MISS", "HIT"] as const)("%s selected card and panels reuse complete re function App() { const controller = useMapPlaceSearch(true); const state = usePlaceSearchStore(); - return <>
    {state.items[0]?.name}
    {state.expanded && state.selectedId ? : null}; + return <>
    {state.items[0]?.name}
    {state.expanded && state.selectedId ? : null}; } const host = document.createElement("div"); const root = createRoot(host); cleanup = async () => { await act(async () => root.unmount()); client.clear(); }; diff --git a/src/components/map/MapPlaceSearch.tsx b/src/components/map/MapPlaceSearch.tsx index 5111df3d..0a51abc7 100644 --- a/src/components/map/MapPlaceSearch.tsx +++ b/src/components/map/MapPlaceSearch.tsx @@ -14,8 +14,8 @@ import { MobileSearchResultCarousel } from "./MobileSearchResultCarousel"; import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; import { useMapPinsFocusStore } from "@/stores/map-pins-focus-store"; -export function MapPlaceSearch({ mobile }: { mobile: boolean }) { - const { confirm, invalidate, scopeKey } = useMapPlaceSearch(mobile); +export function MapPlaceSearch({ mobile }: Readonly<{ mobile: boolean }>) { + const { confirm, invalidate, clearSearch, scopeKey } = useMapPlaceSearch(mobile); const initialQuery = useSearchParams().get("q")?.trim() ?? ""; const status = usePlaceSearchStore((s) => s.status); const error = usePlaceSearchStore((s) => s.error); @@ -27,51 +27,32 @@ export function MapPlaceSearch({ mobile }: { mobile: boolean }) { const focus = useMapPinsFocusStore((s) => s.focus); const { selectedPlace } = useSelectedPlace(); const confirmation = (value: SearchConfirmation) => confirm(value); - const feedback = - status === "pending" ? ( - - ) : status === "error" ? ( -
    - {error} - -
    - ) : status === "done" && !items.length ? ( -

    - 검색 결과가 없어요. -

    - ) : status === "done" && fallback ? ( -

    - 현재 지도 주변의 검색 결과입니다. -

    - ) : null; + const retry = () => { + if (confirmationKind === "specific") { + // A failed server session must be replaced by fresh predictions. + // Return to the chosen-place input instead of changing its intent to whole search. + if (mobile) { + flushSync(() => + useMobileMapSearchStore.getState().openSearch(), + ); + useMobileMapSearchStore.getState().focusInput?.(); + } else inputRef.current?.focus(); + return; + } + void confirm({ query, kind: "whole", session: null, generation: 0 }); + }; + const hasFeedback = status === "pending" || status === "error" || + (status === "done" && (!items.length || fallback)); + const feedback = hasFeedback ? ( + 0} retry={retry} /> + ) : null; return ( <> {mobile ? ( @@ -88,6 +69,7 @@ export function MapPlaceSearch({ mobile }: { mobile: boolean }) { scopeKey={scopeKey} onConfirm={confirmation} onInvalidate={invalidate} + onClear={clearSearch} /> {feedback ? (
    @@ -107,3 +89,25 @@ export function MapPlaceSearch({ mobile }: { mobile: boolean }) { ); } + +function SearchFeedback({ status, error, fallback, hasItems, retry }: Readonly<{ + status: string; + error: string | null; + fallback: boolean; + hasItems: boolean; + retry: () => void; +}>) { + if (status === "pending") return ; + if (status === "error") return ( +
    + {error} + +
    + ); + if (status !== "done") return null; + if (!hasItems) return 검색 결과가 없어요.; + if (fallback) return 현재 지도 주변의 검색 결과입니다.; + return null; +} diff --git a/src/components/map/MobileMapSearch.tsx b/src/components/map/MobileMapSearch.tsx index df9530f9..a8eb89ae 100644 --- a/src/components/map/MobileMapSearch.tsx +++ b/src/components/map/MobileMapSearch.tsx @@ -17,7 +17,8 @@ import { useMapPinsFocusStore } from "@/stores/map-pins-focus-store"; type Props = { confirm: (confirmation: SearchConfirmation) => Promise; - invalidate: (clear: boolean) => void; + invalidate: () => void; + clearSearch: () => void; scopeKey: string; initialQuery?: string; }; @@ -25,9 +26,10 @@ type Props = { export function MobileMapSearch({ confirm, invalidate, + clearSearch, scopeKey, initialQuery, -}: Props) { +}: Readonly) { const textSearchNonce = useMobileMapSearchStore((s) => s.textSearchNonce); const textQuery = useMobileMapSearchStore((s) => s.textQuery); const open = useMobileMapSearchStore((s) => s.open); @@ -125,6 +127,7 @@ export function MobileMapSearch({ onSelectPlace={handleSelectHistory} onConfirm={handleConfirm} onInvalidate={invalidate} + onClear={clearSearch} scopeKey={scopeKey} initialQuery={textSearchNonce === 0 ? initialQuery : undefined} resetKey={!textQuery ? textSearchNonce : 0} diff --git a/src/components/map/MobileSearchResultCarousel.tsx b/src/components/map/MobileSearchResultCarousel.tsx index 05156681..cc3b1b53 100644 --- a/src/components/map/MobileSearchResultCarousel.tsx +++ b/src/components/map/MobileSearchResultCarousel.tsx @@ -66,14 +66,14 @@ function SearchPlaceCard({ active, onExpand, onCollapse, -}: { +}: Readonly<{ item: PlaceSearchResult; analyticsRankBucket?: SearchRankBucket; expanded: boolean; active: boolean; onExpand: () => void; onCollapse: () => void; -}) { +}>) { const [modal, setModal] = useState<"bookmark" | "schedule" | null>(null); const { canSend, sendPlaceMessage } = useChatActions(); const summary = ( diff --git a/src/components/map/SearchPlacePreview.tsx b/src/components/map/SearchPlacePreview.tsx index c04f625e..d7767f84 100644 --- a/src/components/map/SearchPlacePreview.tsx +++ b/src/components/map/SearchPlacePreview.tsx @@ -4,11 +4,13 @@ import { ArrowRight, Star, X } from "lucide-react"; import type { PlaceSearchResult } from "@/hooks/usePlacesSearch"; /** Text Search 응답만 표시한다. 상세 조회는 화살표를 누른 뒤 기존 패널이 담당한다. */ -export function SearchPlacePreview({ place, onOpenDetail, onClose }: { +export function SearchPlacePreview({ place, onOpenDetail, onClose }: Readonly<{ place: PlaceSearchResult; onOpenDetail: () => void; onClose: () => void; -}) { +}>) { + let openingStatus = ""; + if (place.isOpen != null) openingStatus = place.isOpen ? "영업 중" : "영업 종료"; return (
    @@ -28,7 +30,7 @@ export function SearchPlacePreview({ place, onOpenDetail, onClose }: { ) : null}
    - {place.isOpen == null ? "" : place.isOpen ? "영업 중" : "영업 종료"} + {openingStatus} - ) : } + ) : } { @@ -282,7 +305,7 @@ export function MapPlaceSearchInput({ cancel(); token.current?.abandon(); token.current = null; setSuggestions(null); - onInvalidate(false); + onInvalidate(); setOpen(false); } }} @@ -290,7 +313,7 @@ export function MapPlaceSearchInput({ autoComplete="off" className={cn( "min-w-0 flex-1 bg-transparent pr-1 text-text outline-none placeholder:text-text-subtle [&::-webkit-search-cancel-button]:hidden", - mobile ? "text-body-m-regular" : "text-body-s-regular", + appearance.text, )} /> {value ? ( @@ -301,59 +324,74 @@ export function MapPlaceSearchInput({ update(""); fieldRef.current?.focus(); }} - className={cn("flex h-full shrink-0 cursor-pointer items-center justify-center rounded hover:bg-fill focus-visible:outline-2 focus-visible:outline-primary", mobile ? "w-8" : "w-6")} + className={cn("flex h-full shrink-0 cursor-pointer items-center justify-center rounded hover:bg-fill focus-visible:outline-2 focus-visible:outline-primary", appearance.clear)} > - + ) : null}
    - {showDropdown ? ( -
      : null} +
    + ); +} +function SearchSuggestions({ listId, active, value, rows, suggestions, mobile, onConfirm }: Readonly<{ + listId: string; + active: number; + value: string; + rows: PlacePrediction[]; + suggestions: Suggestions | null; + mobile: boolean; + onConfirm: (index: number) => Promise; +}>) { + const iconSize = mobile ? 20 : 16; + // Editable combobox popup: native select/datalist cannot preserve the rich rows and whole-search action. + // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ + return ( +
      + void onConfirm(0)} + /> + {rows.map((prediction, index) => ( + void onConfirm(index + 1)} + place + /> + ))} + {suggestions?.status === "error" ? ( +
    • - void confirm(0)} - /> - {rows.map((prediction, index) => ( - void confirm(index + 1)} - place - /> - ))} - {suggestions?.status === "error" ? ( -
    • - {suggestions.error ?? "검색 추천을 불러오지 못했어요. 다시 입력해주세요."} -
    • - ) : null} -
    + {suggestions.error ?? "검색 추천을 불러오지 못했어요. 다시 입력해주세요."} + ) : null} -
    + ); } + function SuggestionRow({ id, title, @@ -362,7 +400,7 @@ function SuggestionRow({ onClick, place = false, iconSize, -}: { +}: Readonly<{ id: string; title: string | PredictionText | null; subtitle?: string; @@ -370,10 +408,10 @@ function SuggestionRow({ onClick: () => void; place?: boolean; iconSize: number; -}) { +}>) { const Icon = place ? LocationIcon : SearchIcon; return ( -