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/(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/(main)/map/_components/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx index 7f285671..e2bee4b7 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx @@ -151,7 +151,7 @@ export function MobileRouteExpandedShell({ > -
{children}
+
{children}
); } 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..b42911e3 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,31 +20,71 @@ 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); + function renderDetail() { + if (isError && !detail && active) return ( +
+

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

+ +
+ ); + if (showSkeleton) return ( + + + + + ); + return ( + // 장소 상세 시트와 같은 홈·리뷰 탭 — 같은 상세 응답을 쓰므로 추가 호출이 없다 +
+ +
+ ); + } + return (
@@ -54,33 +95,7 @@ export function MobileRoutePlaceDetail({ )}
- {showSkeleton ? ( - - - - - ) : ( - // 장소 상세 시트와 같은 홈·리뷰 탭 — 같은 상세 응답을 쓰므로 추가 호출이 없다 -
- -
- )} + {renderDetail()}
); 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..ae28d0f7 100644 --- a/src/app/(main)/search/page.tsx +++ b/src/app/(main)/search/page.tsx @@ -1,306 +1,20 @@ "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 pathname = isMobileDevice ? "/map" : "/plan"; + const search = query ? `?q=${encodeURIComponent(query)}` : ""; + const target = `${pathname}${search}`; 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/app/globals.css b/src/app/globals.css index c9a88420..a676dc5a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -13,19 +13,21 @@ */ @custom-variant mobile (&:where(.is-mobile-device, .is-mobile-device *)); +/* 검색 말풍선은 Google 기본 여백·스크롤바 대신 본문에서 사방 12px을 확보한다. */ +.gm-style-iw-c:has([data-search-place-preview]) { + padding: 0 !important; /* NOSONAR: S9423 — Google Maps가 주입하는 인라인 padding을 검색 말풍선에서만 재정의한다. */ +} + +.gm-style-iw-d:has([data-search-place-preview]) { + overflow: auto !important; /* NOSONAR: S9423 — Google Maps의 인라인 overflow:scroll에 의한 불필요한 스크롤바를 제거한다. */ +} + @layer base { html { @apply font-sans; } - /** - * 세로 스크롤 컨테이너가 넘치기 전부터 scrollbar 공간을 확보해 - * 스크롤 활성화 시 내부 버튼·카드 너비가 변하지 않게 한다. - * textarea는 입력 영역이 상시 좁아지는 것을 피하기 위해 제외한다. - */ - :where(.overflow-y-auto:not(textarea)) { - scrollbar-gutter: stable; - } + /* 스크롤바 공간 예약은 폭 유지가 필요한 본문·목록에서만 개별 지정한다. */ } /* 모바일 하단 탭바(메뉴 52px + 기기 safe area)가 있을 때만 토스트를 그 위로 올림 */ 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/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..8894a77b --- /dev/null +++ b/src/components/map/Map.search-rank.test.tsx @@ -0,0 +1,476 @@ +// @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"; +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", () => ({ + InfoWindow: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + MapControl: ({ children }: { children: ReactNode }) =>
{children}
, + ControlPosition: { RIGHT_BOTTOM: 9 }, + 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, text: { text: "첫 추천", matches: null }, structuredFormat: null }; +} +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); + 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: [] }], + }); + 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 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 () => { + await controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, + prediction: prediction(), + }); + }); + expect(usePlaceSearchStore.getState().items[0].googlePlaceId).toBe("result-1"); + expect(views()).toHaveLength(0); + 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}`)!; + 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, + }); + expectPrivateFieldsAbsent(views().at(-1)![1]); + } +}); +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( + new Promise((resolve) => { + finish = resolve; + }), + ); + let pending!: Promise; + await act(async () => { + pending = controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, + 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(0); + expect(usePlaceSearchStore.getState().selectedId).toBeNull(); +}); +it("web map-center fallback shows only pins without auto-selection", async () => { + await render(false); + boundary.page.mockResolvedValue({ items, nextPageToken: null }); + await act(async () => { + await controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, + }); + }); + 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); + boundary.page.mockResolvedValue({ items, nextPageToken: null }); + await act(async () => { + await controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, + }); + }); + 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: { sessionToken: "token", expiresAt: Date.now()+60000 }, + 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 does not expose its anchor or emit a detail view", async () => { + await render(false); + boundary.page.mockRejectedValue(new Error("검색 실패")); + await act(async () => { + await controller.confirm({ + query: "원문", + kind: "whole", + generation: 0, + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, + prediction: prediction(), + }); + }); + expect(views()).toHaveLength(0); +}); + +it("cancelling pending search cannot add late pins or detail views", 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: { sessionToken: "token", expiresAt: Date.now()+60000 }, + prediction: prediction(), + }); + }); + await vi.waitFor(() => expect(boundary.page).toHaveBeenCalled()); + expect(views()).toHaveLength(0); + await act(async () => controller.invalidate()); + expect(views()).toHaveLength(0); + await act(async () => { + finish({ items, nextPageToken: null }); + await pending; + }); + expect(views()).toHaveLength(0); + 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: { sessionToken: "token", expiresAt: Date.now()+60000 }, + }); + }); + const fourth = [ + ...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(), + ); + 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 cancels pending search without showing its anchor", 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: { sessionToken: "token", expiresAt: Date.now()+60000 }, + prediction: prediction(), + }); + }); + await vi.waitFor(() => expect(boundary.page).toHaveBeenCalled()); + expect(views()).toHaveLength(0); + expect(host.textContent).not.toContain("결과1"); + await act(async () => useMapPinsFocusStore.getState().claimFocus("discover")); + expect(views()).toHaveLength(0); + expect(host.textContent).not.toContain("결과1"); + await act(async () => { + finish({ items, nextPageToken: null }); + await pending; + }); + expect(views()).toHaveLength(0); + 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..2b7ede63 --- /dev/null +++ b/src/components/map/Map.search-selection.test.tsx @@ -0,0 +1,296 @@ +// @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"; +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", () => ({ + MapControl: ({ children }: { children: ReactNode }) =>
{children}
, + ControlPosition: { RIGHT_BOTTOM: 9 }, + 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 } }, + }); + 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: [] }], + }); + 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", text: null, structuredFormat: null } : undefined; + let pending!: Promise; + await act(async () => { + pending = api.confirm({ + query: "원래 검색어", + kind: "whole", + session: { sessionToken: "token", expiresAt: Date.now()+60000 }, + 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 } }, + }); + 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: [] }], + }); + 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 88ccf57e..0192a6e1 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, ControlPosition, Map as GoogleMap, MapControl, @@ -38,6 +39,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"; @@ -54,6 +57,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"; @@ -284,11 +288,45 @@ 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(); +} + +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 selectedPlaceId = selectedPlace?.googlePlaceId?.trim() ?? ""; + 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; @@ -302,6 +340,17 @@ 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 && kind === "specific") { + setSelectedPlace(result, { itinerarySource: "search", analyticsSource: "search", preserveMapZoom: true }); + } else { + setSelectedPlace(null); + } + return; + } + releaseSearchForMapSelection(); setSelectedPlace( { name: pin.name, @@ -317,6 +366,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} ); } @@ -447,6 +512,7 @@ export default function TripMap({ return; } if (placeId.length > 0) { + releaseSearchForMapSelection(); ev.stop(); const latLng = ev.detail.latLng; setSelectedPlace( @@ -462,7 +528,9 @@ export default function TripMap({ ); return; } - // 빈 곳을 누르면 상세를 닫고, 웹 일정에서 고른 장소(강조·일차 경로만 보기)도 푼다 + // 빈 곳은 완료된 검색 핀·카드를 유지한다. 진행 중인 응답만 무효화하고 상세·일정 강조를 닫는다. + if (usePlaceSearchStore.getState().status === "pending") usePlaceSearchStore.getState().invalidate(); + if (!isMobileDevice) usePlaceSearchStore.getState().select(null); setSelectedPlace(null); clearPlanFocus(); }; @@ -524,6 +592,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..9478bd1c 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} > @@ -103,20 +104,20 @@ function MobileMapSearchBar() { } return ( -
+
{textQuery ? ( @@ -124,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.server-session.test.tsx b/src/components/map/MapPlaceSearch.server-session.test.tsx new file mode 100644 index 00000000..43b949f7 --- /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 new file mode 100644 index 00000000..554b203e --- /dev/null +++ b/src/components/map/MapPlaceSearch.test.tsx @@ -0,0 +1,107 @@ +// @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"; +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/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", 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 } }); + 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, 280)); + }); + 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(); + }); + 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, 280)); + }); + expect(JSON.parse(autocompleteCalls().at(-1)![1]!.body as string).sessionToken).not.toBe( + token, + ); + await act(async () => { + ( + host.querySelectorAll('[role="option"] button')[1] as HTMLButtonElement + ).click(); + }); + 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 new file mode 100644 index 00000000..0a51abc7 --- /dev/null +++ b/src/components/map/MapPlaceSearch.tsx @@ -0,0 +1,113 @@ +"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 }: 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); + 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 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 ? ( + + ) : ( +
+ + {feedback ? ( +
+ {feedback} +
+ ) : null} +
+ )} + {mobile && feedback ? ( +
+ {feedback} +
+ ) : null} + {mobile && focus === "search" && !selectedPlace ? ( + + ) : null} + + ); +} + +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/MapWithDetailPanel.tsx b/src/components/map/MapWithDetailPanel.tsx index 8c2ad120..b1bc2f1a 100644 --- a/src/components/map/MapWithDetailPanel.tsx +++ b/src/components/map/MapWithDetailPanel.tsx @@ -10,7 +10,7 @@ import { MAIN_MAP_MIN_WIDTH_PX } from "@/lib/layout-tokens"; import { cn } from "@/lib/utils"; import Map, { type MapRouteView } from "./Map"; -import { MobileMapSearch } from "./MobileMapSearch"; +import { MapPlaceSearch } from "./MapPlaceSearch"; export function MapWithDetailPanel({ mobileInline = false, @@ -47,7 +47,7 @@ export function MapWithDetailPanel({ aria-hidden={hidden || undefined} style={hidden ? { display: "none" } : visibleStyle} 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", @@ -87,7 +87,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..9badd0ff --- /dev/null +++ b/src/components/map/MobileMapSearch.history.test.tsx @@ -0,0 +1,111 @@ +// @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", async original => ({ + ...(await original()), + 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 replaces the existing POI with the chosen place detail", 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]")).toBeNull(); + 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..a8eb89ae 100644 --- a/src/components/map/MobileMapSearch.tsx +++ b/src/components/map/MobileMapSearch.tsx @@ -1,101 +1,136 @@ "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 { fetchPlaceDetail, normalizePreviewLocation } 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: () => void; + clearSearch: () => void; + scopeKey: string; + initialQuery?: string; +}; +/** Existing mobile entry, with session-connected confirmation and cached result cards. */ +export function MobileMapSearch({ + confirm, + invalidate, + clearSearch, + scopeKey, + initialQuery, +}: Readonly) { + 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 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 || "", + rating: detail.rating, + userRatingCount: detail.userRatingCount, + isOpen: detail.regularOpeningHours?.openNow ?? null, + location, + }; + useMapPinsFocusStore.getState().claimFocus("search"); + usePlaceSearchStore.setState({ + items: [item], + searchRankBuckets: {}, + selectedId: item.googlePlaceId, + expanded: false, + status: "done", + error: null, + fallback: false, + termination: "not-requested", + resolution: null, + query: item.name, + confirmationKind: "specific", + }); + useSearchRecenterStore.getState().clearSearchSnapshot(); + setActiveSearchMapPins(placeSearchResultsToMapPins([item])); + setSelectedPlace(item, { + itinerarySource: "search", + analyticsSource: "search", + }); + 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..cc3b1b53 --- /dev/null +++ b/src/components/map/MobileSearchResultCarousel.tsx @@ -0,0 +1,199 @@ +"use client"; + +import type { SearchRankBucket } from "@/lib/analytics/context"; +import { useState } from "react"; +import { toast } from "sonner"; +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"; +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, +}: 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 = ( +
+ {!expanded ? ( + +
+
+ + +
+ + + ); + return ( + <> + {expanded ? ( + + {summary} + + + ) : ( +
+ {summary} +
+ )} + {modal === "bookmark" ? ( + setModal(null)} + /> + ) : null} + {modal === "schedule" ? ( + setModal(null)} + /> + ) : null} + + ); +} diff --git a/src/components/map/SearchPlacePreview.tsx b/src/components/map/SearchPlacePreview.tsx new file mode 100644 index 00000000..d7767f84 --- /dev/null +++ b/src/components/map/SearchPlacePreview.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { ArrowRight, Star, X } from "lucide-react"; +import type { PlaceSearchResult } from "@/hooks/usePlacesSearch"; + +/** Text Search 응답만 표시한다. 상세 조회는 화살표를 누른 뒤 기존 패널이 담당한다. */ +export function SearchPlacePreview({ place, onOpenDetail, onClose }: Readonly<{ + place: PlaceSearchResult; + onOpenDetail: () => void; + onClose: () => void; +}>) { + let openingStatus = ""; + if (place.isOpen != null) openingStatus = place.isOpen ? "영업 중" : "영업 종료"; + return ( +
+
+

{place.name}

+ +
+ {place.category ?

{place.category}

: null} + {place.rating != null ? ( +
+ + {place.rating.toFixed(1)} + {place.userRatingCount != null ? ({place.userRatingCount.toLocaleString()}개) : null} +
+ ) : null} +
+ + {openingStatus} + + +
+
+ ); +} diff --git a/src/components/map/useMapPlaceSearch.test.tsx b/src/components/map/useMapPlaceSearch.test.tsx new file mode 100644 index 00000000..77a8a364 --- /dev/null +++ b/src/components/map/useMapPlaceSearch.test.tsx @@ -0,0 +1,252 @@ +// @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 { 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(), + detail: vi.fn(), + select: vi.fn(), + track: vi.fn(), + suggest: vi.fn(), + user: 7, +})); +vi.mock("@/hooks/usePlacesSearch", () => ({ + fetchPlacesPageWithPhotos: network.page, +})); +vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() })); +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, +})); +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") { + return { + 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; + 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: [ + { + 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 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()); + 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 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(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); + 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; + 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()); + 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, 280)); + }); + 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 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; })); + const pending = api.confirm(confirmation()); + await vi.waitFor(() => expect(network.page).toHaveBeenCalled()); + expect(network.select).toHaveBeenLastCalledWith(null); + await act(async () => api.invalidate()); + 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 () => { + 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 new file mode 100644 index 00000000..807625ef --- /dev/null +++ b/src/components/map/useMapPlaceSearch.ts @@ -0,0 +1,271 @@ +"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 { placeDetailQueryKey } from "@/lib/places/place-queries"; +import { PLACES_SEARCH_PAGE_SIZE } from "@/lib/places/placesSearchPageSize"; +import { + confirmPlaceSearch, + type ResolvedSearchAnchor, + 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 clearSearch() { + usePlaceSearchStore.getState().clear(); + clearActiveSearchMapPins(); + useSearchRecenterStore.getState().clearSearchSnapshot(); + useMapPinsFocusStore.getState().releaseSearchFocusIfActive(); + setSelectedPlace(null); + } + + function invalidate() { + 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(); + }); + useEffect(() => { + if (pinsFocus !== "search") settleOwnershipLoss(); + }, [pinsFocus]); + + function applySearchResult( + result: NonNullable>>>, + confirmation: SearchConfirmation, + mode: "text" | "map_recenter", + ) { + 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) { + items = [toAnchor(result.anchor, confirmation)]; + useSearchRecenterStore.getState().clearSearchSnapshot(); + } + const selectedId = mobile || confirmation.kind === "specific" + ? (items[0]?.googlePlaceId ?? null) + : null; + usePlaceSearchStore.setState({ + items, + searchRankBuckets, + selectedId, + status: "done", + fallback: result.fallback, + termination: result.termination, + }); + setActiveSearchMapPins(placeSearchResultsToMapPins(items)); + if (confirmation.kind === "specific") { + const selected = result.anchor + ? (items.find( + (item) => item.googlePlaceId === result.anchor!.googlePlaceId, + ) ?? toAnchor(result.anchor, confirmation)) + : (items[0] ?? null); + setSelectedPlace(selected, { + itinerarySource: "search", + analyticsSource: "search", + analyticsRankBucket: selected + ? searchRankBuckets[selected.googlePlaceId] + : undefined, + }); + } + if (confirmation.kind === "whole") { + trackAnalyticsEvent(AnalyticsEvents.search, { + result_count_bucket: bucketResultCount(result.items.length), + search_mode: mode, + ...(roomId ? { room_id: roomId } : {}), + }); + } + } + + 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", + resolution: null, + }); + setSelectedPlace(null); + if (!mobile) usePlaceSearchStore.setState({ selectedId: null }); + if (mobile) { + useMobileMapSearchStore.setState({ textQuery: query }); + } + try { + const result = await confirmPlaceSearch({ + ...confirmation, + mapCenter, + isCurrent, + onResolved: (resolved) => { + usePlaceSearchStore.setState({ + termination: resolved.termination, + resolution: { + source: resolved.source, + cacheStatus: resolved.cacheStatus, + googleDetailsRequested: resolved.googleDetailsRequested, + termination: resolved.termination, + }, + }); + }, + 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; + applySearchResult(result, confirmation, mode); + return true; + } catch (error) { + if (!isCurrent()) return false; + usePlaceSearchStore.setState({ + status: "error", + termination: failedTermination(confirmation), + error: + error instanceof Error + ? error.message + : "검색에 실패했어요. 다시 시도해주세요.", + }); + return false; + } + } + const recenter = useEffectEvent(() => { + const query = usePlaceSearchStore.getState().query; + if (query) + void confirm( + { query, kind: "whole", session: null, generation: 0 }, + "map_recenter", + ); + }); + useEffect(() => { + if (seenRecenter.current !== recenterId) { + seenRecenter.current = recenterId; + recenter(); + } + }, [recenterId]); + const clearLegacy = useEffectEvent(() => { + if (!legacyQuery && mobile && usePlaceSearchStore.getState().query) + clearSearch(); + }); + useEffect(() => { + if (seenLegacyNonce.current !== legacyNonce) { + seenLegacyNonce.current = legacyNonce; + clearLegacy(); + } + }, [legacyNonce, legacyQuery]); + + return { confirm, invalidate, clearSearch, scopeKey }; +} + +function toAnchor( + anchor: ResolvedSearchAnchor, + confirmation: SearchConfirmation, +): PlaceSearchResult { + return { + googlePlaceId: anchor.googlePlaceId, + name: + 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, + }; +} + +function failedTermination(confirmation: SearchConfirmation) { + const termination = usePlaceSearchStore.getState().termination; + if (termination === "SUCCEEDED" || termination === "NOT_TERMINATED") return termination; + return confirmation.prediction ? "failed" : "not-requested"; +} 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/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..7acebf3c 100644 --- a/src/components/place/PlaceDetailPanel.tsx +++ b/src/components/place/PlaceDetailPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowLeft, X } from "lucide-react"; +import { X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -28,7 +28,7 @@ import { PlaceDetailTabs } from "./PlaceDetailTabs"; type PlaceDetailPanelProps = SearchResultCardProps & { onClose: () => void; - /** 뒤로가기(←) — 없으면 `onClose`와 같다 */ + /** 모바일 시트 뒤로가기(←) — 없으면 `onClose`와 같다 */ onBack?: () => void; /** * `panel`(데스크톱): 사진 고정, 아래 본문만 스크롤. @@ -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, @@ -65,6 +66,7 @@ export function PlaceDetailPanel({ const { analyticsRankBucket, + analyticsRankPending, analyticsSource, itinerarySource: selectedItinerarySource, } = useSelectedPlace(); @@ -74,7 +76,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 +96,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 +108,13 @@ export function PlaceDetailPanel({ }); }, [ analyticsRankBucket, + analyticsRankPending, analyticsSource, analyticsRoomId, displayCategory, googlePlaceId, isDetailLoading, + isDetailError, itinerarySource, ]); const displayRating = detailData?.rating ?? rating; @@ -190,7 +194,12 @@ export function PlaceDetailPanel({ ? () => setBookmarkModalOpen(true) : undefined; - const tabs = ( + const tabs = isDetailError && !detailData ? ( +
+

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

+ +
+ ) : ( {body}
{/* 닫기 버튼 — 스크롤과 무관하게 패널 상단에 고정 */} - + + ) : 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..b8e14e7f --- /dev/null +++ b/src/components/search/MapPlaceSearchInput.test.tsx @@ -0,0 +1,195 @@ +// @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"; +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", + 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(); + 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"); + 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: 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(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 = JSON.parse(autocompleteCalls()[0][1]!.body as string).sessionToken; + await key("Enter"); + await type("다음"); + await wait(); + 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("전체 실제 이름"); +}); + +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 new file mode 100644 index 00000000..38ac7da1 --- /dev/null +++ b/src/components/search/MapPlaceSearchInput.tsx @@ -0,0 +1,459 @@ +"use client"; + +import { + useEffect, + useEffectEvent, + useId, + useRef, + useState, + type RefObject, +} from "react"; +import { ChevronLeftIcon, CircleCancelIcon, LocationIcon, SearchIcon } from "@/assets/icons"; +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"; + +export type MapPlaceSearchInputProps = Readonly<{ + onConfirm: (confirmation: SearchConfirmation) => Promise; + onInvalidate: () => void; + onClear: () => void; + scopeKey: string; + inputRef?: RefObject; + mobile?: boolean; + initialQuery?: string; + active?: boolean; + autoConfirm?: boolean; + onQueryChange?: (query: string) => void; + onBack?: () => void; + backLabel?: string; +}>; +type Suggestions = { + query: string; + items: PlacePrediction[]; + session: PlaceSession | null; + status: "done" | "error"; + error?: string; +}; + +const INPUT_STYLES = { + mobile: { + container: "flex min-h-0 w-full flex-col", + form: "shrink-0 px-5 pb-2 pt-4", + field: "h-12 gap-2 rounded-lg pl-3.5 pr-2.5", + text: "text-body-m-regular", + clear: "w-8", + iconSize: 20, + }, + desktop: { + container: "w-[280px] max-w-[calc(100vw-32px)]", + form: undefined, + field: "h-9 gap-1.5 rounded-md px-2.5", + text: "text-body-s-regular", + clear: "w-6", + iconSize: 16, + }, +} as const; + +/** Shared web/mobile map search. Original input and prediction/token identity survive confirmation. */ +export function MapPlaceSearchInput({ + onConfirm, + onInvalidate, + onClear, + scopeKey, + inputRef, + mobile = false, + initialQuery = "", + active: enabled = true, + autoConfirm = false, + onQueryChange, + onBack, + backLabel = "지도로 돌아가기", +}: MapPlaceSearchInputProps) { + 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?.abandon(); + 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(); + token.current?.abandon(); + token.current = null; + }; + }, [scopeKey]); + + function schedule(query: string) { + cancel(); + setSuggestions(null); + setActive(-1); + if (!query.trim() || !mapCenter || !enabled) return; + const requestGeneration = generation.current; + timer.current = setTimeout(() => { + 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" }); + }, + (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 }); + } + }, + ); + }, MAP_AUTOCOMPLETE_DEBOUNCE_MS); + } + const retryReady = useEffectEvent(() => { + if (value.trim() && open) schedule(value); + }); + useEffect(() => { + if (mapCenter && enabled) retryReady(); + }, [mapCenter, enabled]); + useEffect(() => { + if (!enabled) { + cancel(); token.current?.abandon(); token.current = null; + setSuggestions(null); + } + }, [enabled]); + + function update(query: string) { + autoQuery.current = null; + if (query.trim()) onInvalidate(); + else onClear(); + setValue(query); + onQueryChange?.(query); + setOpen(Boolean(query.trim())); + confirmGeneration.current = null; + setPending(false); + if (!query.trim()) { token.current?.abandon(); 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; + 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); + fieldRef.current?.blur(); + try { + 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); + 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()); + const appearance = INPUT_STYLES[mobile ? "mobile" : "desktop"]; + return ( +
+
{ + event.preventDefault(); + void confirm(active); + }} + > +
+ {onBack ? ( + + ) : } + = 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?.abandon(); token.current = null; + setSuggestions(null); + onInvalidate(); + 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?.abandon(); token.current = null; + setSuggestions(null); + onInvalidate(); + setOpen(false); + } + }} + placeholder="장소 검색" + 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", + appearance.text, + )} + /> + {value ? ( + + ) : 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" ? ( +
  • + {suggestions.error ?? "검색 추천을 불러오지 못했어요. 다시 입력해주세요."} +
  • + ) : null} +
+ ); +} + +function SuggestionRow({ + id, + title, + subtitle, + active, + onClick, + place = false, + iconSize, +}: Readonly<{ + id: string; + title: string | PredictionText | null; + subtitle?: string; + active: boolean; + onClick: () => void; + place?: boolean; + iconSize: number; +}>) { + const Icon = place ? LocationIcon : SearchIcon; + return ( +
  • + +
  • + ); +} + +/** Backend offsets count Unicode code points, not UTF-16 code units. */ +function HighlightedText({ value }: Readonly<{ value: PredictionText | null }>) { + if (!value?.text) return null; + const segments: { id: string; text: string; highlighted: boolean }[] = []; + for (const [offset, point] of Array.from(value.text).entries()) { + const highlighted = value.matches?.some(match => offset >= match.startOffset && offset < match.endOffset) ?? false; + const previous = segments.at(-1); + if (previous?.highlighted === highlighted) { + previous.text += point; + } else { + segments.push({ id: `${value.text}:${offset}`, text: point, highlighted }); + } + } + return segments.map(segment => segment.highlighted + ? {segment.text} + : segment.text); +} diff --git a/src/components/search/MobilePlaceSearchScreen.tsx b/src/components/search/MobilePlaceSearchScreen.tsx index 93cb365b..2b86add9 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.onClear && 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/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..e83c1ff0 --- /dev/null +++ b/src/lib/api/place-sessions.ts @@ -0,0 +1,73 @@ +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 }; +function parseRetryAfterMs(retryAfter: string | null): number | null { + if (retryAfter === null) return null; + const milliseconds = /^\d+$/.test(retryAfter) + ? Number(retryAfter) * 1000 + : Math.max(0, Date.parse(retryAfter) - Date.now()); + return Number.isFinite(milliseconds) ? milliseconds : null; +} +async function responseError(res: Response, bucket: keyof typeof bucketWaits): Promise { + const raw = await tryParseJson(res); + const error = raw && typeof raw === "object" ? raw as Record : {}; + const retryAfterMs = parseRetryAfterMs(res.headers.get("Retry-After")); + if (res.status === 429) bucketWaits[bucket] = Date.now() + (retryAfterMs ?? 60000); + return new PlaceSessionError( + res.status, + typeof error.code === "string" ? error.code : "UNKNOWN", + typeof error.message === "string" ? error.message : "장소 검색에 실패했어요. 다시 입력해주세요.", + retryAfterMs, + ); +} +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) throw await responseError(res, bucket); + 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/layout/scrollbar-gutter.test.ts b/src/lib/layout/scrollbar-gutter.test.ts index f3b1a69d..8b8e550d 100644 --- a/src/lib/layout/scrollbar-gutter.test.ts +++ b/src/lib/layout/scrollbar-gutter.test.ts @@ -8,13 +8,7 @@ const globalStyles = readFileSync( "utf8", ); -describe("전역 세로 스크롤 영역", () => { - it("스크롤바가 생기기 전부터 gutter를 확보하되 textarea는 제외한다", () => { - expect(globalStyles).toMatch( - /:where\(\.overflow-y-auto:not\(textarea\)\)\s*\{[^}]*scrollbar-gutter:\s*stable;/, - ); - }); - +describe("채팅 전용 스크롤 영역", () => { it("채팅 메시지 스크롤 영역도 gutter를 안정적으로 유지한다", () => { expect(globalStyles).toMatch( /\.chat-message-list-scroll\s*\{[^}]*scrollbar-gutter:\s*stable;/, diff --git a/src/lib/places/autocomplete-session.ts b/src/lib/places/autocomplete-session.ts new file mode 100644 index 00000000..561da06c --- /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 readonly 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 new file mode 100644 index 00000000..5438d7eb --- /dev/null +++ b/src/lib/places/place-detail-cache.test.tsx @@ -0,0 +1,87 @@ +// @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 { 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("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 } }, + }); + 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시"], secondaryHoursType: null, specialDays: [], periods: [], nextOpenTime: null, nextCloseTime: null }, + reviewSummary: "요약", + reviews: [ + { + rating: 5, + text: "리뷰", + authorDisplayName: "작성자", + publishTime: "2026-01-01", + relativePublishTimeDescription: "최근", + }, + ], + }; + vi.mocked(requestPlaceDetail).mockReset().mockResolvedValue(detail); + 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"); + useEffect(() => { + view = result; + }); + return null; + } + const root = createRoot(document.createElement("div")); + await act(async () => + root.render( + + + , + ), + ); + expect(requestPlaceDetail).not.toHaveBeenCalled(); + expect(apiFetch).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..005ff847 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) { @@ -33,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/placesSearchPageSize.test.ts b/src/lib/places/placesSearchPageSize.test.ts index 5ffca5be..e89ff7d8 100644 --- a/src/lib/places/placesSearchPageSize.test.ts +++ b/src/lib/places/placesSearchPageSize.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { PLACES_SEARCH_PAGE_SIZE } from "@/lib/places/placesSearchPageSize"; describe("PLACES_SEARCH_PAGE_SIZE", () => { - it("keeps search results to five visible candidates per page", () => { - expect(PLACES_SEARCH_PAGE_SIZE).toBe(5); + it("requests up to ten candidates per page", () => { + expect(PLACES_SEARCH_PAGE_SIZE).toBe(10); }); }); diff --git a/src/lib/places/placesSearchPageSize.ts b/src/lib/places/placesSearchPageSize.ts index d853870d..34a31451 100644 --- a/src/lib/places/placesSearchPageSize.ts +++ b/src/lib/places/placesSearchPageSize.ts @@ -1,2 +1,2 @@ /** Search 페이지 `GET /places/search` 요청당 장소 개수 (서버 pageSize 1~20) */ -export const PLACES_SEARCH_PAGE_SIZE = 5; +export const PLACES_SEARCH_PAGE_SIZE = 10; diff --git a/src/lib/places/search-session.test.ts b/src/lib/places/search-session.test.ts new file mode 100644 index 00000000..34a5b244 --- /dev/null +++ b/src/lib/places/search-session.test.ts @@ -0,0 +1,147 @@ +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 search = vi.fn().mockResolvedValue([{ googlePlaceId: "other" }]); + 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("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("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(apiFetch).toHaveBeenCalledTimes(1); + expect(result?.termination).toBe("NOT_TERMINATED"); + expect(result?.anchor?.source).toBe("CACHE"); + }); + it("specific resolves full response with zero Text Search", async () => { + const { args, search } = setup({ kind: "specific" }); + expect((await confirmPlaceSearch(args))?.anchor?.detail.rating).toBe(4.5); + 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("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("late Text Search suppressed", async () => { + let finish!: (v: unknown[]) => void; let current = true; + const { args, search } = setup({ isCurrent: () => current }); + 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("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("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 new file mode 100644 index 00000000..f4a30ce9 --- /dev/null +++ b/src/lib/places/search-session.ts @@ -0,0 +1,105 @@ +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?: PlacePrediction; + /** Issued token from the successful autocomplete that supplied this prediction. */ + session: PlaceSession | null; + generation: number; +}; +export type ResolvedSearchAnchor = PlaceResolveResponse & { + googlePlaceId: string; + location: google.maps.LatLngLiteral; +}; +const resolutions = new WeakMap; +}>(); +async function resolveWithInProgressRetry( + session: PlaceSession, googlePlaceId: string, isCurrent: () => boolean, +): Promise { + try { + return await resolvePlaceSession(session.sessionToken, googlePlaceId); + } catch (error) { + // Only IN_PROGRESS is known to permit a bounded delayed same-token/place retry. + if (!(error instanceof PlaceSessionError) || error.code !== "PLACE_SESSION_IN_PROGRESS") throw error; + await new Promise(resolve => setTimeout(resolve, 300)); + if (!isCurrent()) throw new DOMException("Search abandoned", "AbortError"); + } + return resolvePlaceSession(session.sessionToken, googlePlaceId); +} +/** Errors remain tied to this operation; cancel/timeout never prove Google was not called. */ +export function terminatePredictionSession( + 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 = resolveWithInProgressRetry(session, prediction.placeId, isCurrent); + resolutions.set(session, { googlePlaceId: prediction.placeId, promise }); + return promise; +} +type ConfirmPlaceSearchArgs = 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; +}; +type SearchResolution = { + anchor: ResolvedSearchAnchor | null; + termination: PlaceResolveResponse["termination"] | "failed" | "not-requested"; +}; +async function resolveSearchPrediction( + args: ConfirmPlaceSearchArgs, prediction: PlacePrediction, +): Promise { + let anchor: ResolvedSearchAnchor | null = null; + let termination: SearchResolution["termination"] = "not-requested"; + try { + const resolved = await terminatePredictionSession(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) { + // Coordinate absence does not erase a verified server termination. + if (termination === "not-requested") termination = "failed"; + if (args.kind === "specific" && args.isCurrent()) throw error; + } + return { anchor, termination }; +} +export async function confirmPlaceSearch(args: ConfirmPlaceSearchArgs): Promise< + SearchResolution & { items: T[]; fallback: boolean } | null +> { + const query = args.query.trim(); + if (!query) throw new Error("검색어를 입력해주세요."); + if (!args.isCurrent()) return null; + const resolution = args.prediction + ? await resolveSearchPrediction(args, args.prediction) + : { anchor: null, termination: "not-requested" as const }; + if (!resolution) return null; + const { anchor, termination } = resolution; + 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..96126b1a --- /dev/null +++ b/src/stores/place-search-store.ts @@ -0,0 +1,65 @@ +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"; + +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: PlaceResolveResponse["termination"] | "failed" | "not-requested"; + resolution: Omit | null; + setScope: (scope: string) => void; + invalidate: () => number; + select: (id: string | null) => 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, + resolution: null, +}; +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 }), +}));