diff --git a/public/map-search/arrow-right.svg b/public/map-search/arrow-right.svg new file mode 100644 index 00000000..e03a7f79 --- /dev/null +++ b/public/map-search/arrow-right.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/map-search/attraction.svg b/public/map-search/attraction.svg new file mode 100644 index 00000000..30038055 --- /dev/null +++ b/public/map-search/attraction.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/map-search/chevron-down.svg b/public/map-search/chevron-down.svg new file mode 100644 index 00000000..688e5216 --- /dev/null +++ b/public/map-search/chevron-down.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/map-search/clear.svg b/public/map-search/clear.svg new file mode 100644 index 00000000..129f6879 --- /dev/null +++ b/public/map-search/clear.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map-search/close.svg b/public/map-search/close.svg new file mode 100644 index 00000000..6712c055 --- /dev/null +++ b/public/map-search/close.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map-search/hotel.svg b/public/map-search/hotel.svg new file mode 100644 index 00000000..e3fad22b --- /dev/null +++ b/public/map-search/hotel.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/map-search/location.svg b/public/map-search/location.svg new file mode 100644 index 00000000..c0bdec3e --- /dev/null +++ b/public/map-search/location.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map-search/museum.svg b/public/map-search/museum.svg new file mode 100644 index 00000000..50eb214f --- /dev/null +++ b/public/map-search/museum.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/map-search/pointer.svg b/public/map-search/pointer.svg new file mode 100644 index 00000000..a6416b71 --- /dev/null +++ b/public/map-search/pointer.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/map-search/reset.svg b/public/map-search/reset.svg new file mode 100644 index 00000000..f0c45e54 --- /dev/null +++ b/public/map-search/reset.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map-search/restaurant.svg b/public/map-search/restaurant.svg new file mode 100644 index 00000000..09b7a658 --- /dev/null +++ b/public/map-search/restaurant.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/map-search/search-white.svg b/public/map-search/search-white.svg new file mode 100644 index 00000000..97f48bc0 --- /dev/null +++ b/public/map-search/search-white.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map-search/search.svg b/public/map-search/search.svg new file mode 100644 index 00000000..11b446ba --- /dev/null +++ b/public/map-search/search.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map-search/star.svg b/public/map-search/star.svg new file mode 100644 index 00000000..357f0547 --- /dev/null +++ b/public/map-search/star.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/map-search/transit.svg b/public/map-search/transit.svg new file mode 100644 index 00000000..721fffd2 --- /dev/null +++ b/public/map-search/transit.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/app/globals.css b/src/app/globals.css index 84be1375..ea5ef06f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -13,8 +13,11 @@ */ @custom-variant mobile (&:where(.is-mobile-device, .is-mobile-device *)); -/* 검색 말풍선은 Google 기본 여백·스크롤바 대신 본문에서 사방 12px을 확보한다. */ +/* 검색 말풍선의 여백·표면·그림자는 Figma 6156:14635 기준으로 맞춘다. */ .gm-style-iw-c:has([data-search-place-preview]) { + border-radius: 8px; + background: var(--color-fill-subtle); + box-shadow: 0 2px 10px #0000001a; padding: 0 !important; /* NOSONAR: S9423 — Google Maps가 주입하는 인라인 padding을 검색 말풍선에서만 재정의한다. */ } @@ -22,6 +25,28 @@ overflow: auto !important; /* NOSONAR: S9423 — Google Maps의 인라인 overflow:scroll에 의한 불필요한 스크롤바를 제거한다. */ } +/* 검색 추천은 기존 상세 패널(z-20) 위에서 선택할 수 있어야 한다. */ +.map-discover-toolbar:has(input[aria-expanded="true"]) { + z-index: 25; +} + +/* 원본 말풍선 꼬리(14×12)를 Google 기본 삼각형 대신 사용한다. */ +.gm-style-iw-t:has([data-search-place-preview]) > .gm-style-iw-tc { + width: 14px; + height: 12px; + filter: drop-shadow(0 2px 5px #0000001a); +} + +.gm-style-iw-t:has([data-search-place-preview]) > .gm-style-iw-tc::after { + inset: 0; + width: 14px; + height: 12px; + clip-path: none; + background: url("/map-search/pointer.svg") center / contain no-repeat; + transform: rotate(180deg); + box-shadow: none; +} + /* PC 지도 버튼은 지도 끝에서 아래·좌우 모두 24px — 확대·축소(+/−)의 아래는 저작권 줄과 기본 여백으로 이미 24px이라 오른쪽만 넓힌다. */ [data-map-controls-inset] .gm-bundled-control { margin-right: 24px !important; /* NOSONAR: S9423 — Google Maps가 주입하는 인라인 margin(10px)을 재정의한다. */ diff --git a/src/app/login/page.test.tsx b/src/app/login/page.test.tsx index d5df5b70..c5a407ef 100644 --- a/src/app/login/page.test.tsx +++ b/src/app/login/page.test.tsx @@ -16,11 +16,18 @@ vi.mock("@/lib/google-oauth", () => ({ saveOAuthPendingSession: vi.fn(), })); -import { LoginFallback } from "@/app/login/page"; +vi.mock("next/navigation", () => ({ + useSearchParams: () => { + // Exercise the page's real Suspense fallback while navigation data is pending. + throw new Promise(() => {}); + }, +})); + +import LoginPage from "@/app/login/page"; describe("LoginFallback", () => { it("renders meaningful non-interactive login HTML", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); expect(html).toContain("로그인하고 여행 계획을 이어가세요"); expect(html).toContain("Google로 계속하기"); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 0e01c355..b165cc8d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -108,7 +108,7 @@ function LoginPageContent() { ); } -export function LoginFallback() { +function LoginFallback() { return ( - {open && ( + {open && createPortal(
@@ -79,6 +113,7 @@ export function FilterDropdown({ onClick={() => { onChange(opt.value); setOpen(false); + triggerRef.current?.focus(); }} className={cn( "w-full cursor-pointer px-3 py-2 text-left text-label-m-regular mobile:text-label-s-regular transition hover:bg-gray-50", @@ -90,7 +125,7 @@ export function FilterDropdown({ {opt.label} ))} -
+ , document.body, )} ); diff --git a/src/components/map/Map.search-rank.test.tsx b/src/components/map/Map.search-rank.test.tsx index 8894a77b..70238ffe 100644 --- a/src/components/map/Map.search-rank.test.tsx +++ b/src/components/map/Map.search-rank.test.tsx @@ -1,7 +1,7 @@ // @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 { act, useEffect, type ReactNode, type Ref } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, expect, it, vi } from "vitest"; @@ -52,8 +52,8 @@ vi.mock("@/lib/analytics/track", () => ({ trackAnalyticsEvent: boundary.track, })); vi.mock("@vis.gl/react-google-maps", () => ({ - InfoWindow: ({ children }: { children: ReactNode }) => ( -
{children}
+ InfoWindow: ({ children, anchor }: { children: ReactNode; anchor?: HTMLElement }) => ( +
{children}
), MapControl: ({ children }: { children: ReactNode }) =>
{children}
, ControlPosition: { RIGHT_BOTTOM: 9 }, @@ -87,11 +87,15 @@ vi.mock("@vis.gl/react-google-maps", () => ({ AdvancedMarker: ({ children, onClick, + zIndex, + ref, }: { children: ReactNode; + zIndex?: number; + ref?: Ref; onClick?: (event: { stop: () => void }) => void; }) => ( - ), @@ -223,6 +227,9 @@ it("web preview closes independently and switching pins never fetches or opens d await act(async () => pin("결과4").click()); expect(host.querySelectorAll('[role="dialog"]')).toHaveLength(1); expect(host.querySelector('[role="dialog"]')?.textContent).toContain("결과4"); + expect(host.querySelector('[role="dialog"]')?.getAttribute("data-anchor-name")).toBe("결과4"); + await act(async () => pin("결과5").click()); + expect(host.querySelector('[role="dialog"]')?.getAttribute("data-anchor-name")).toBe("결과5"); expect(views()).toHaveLength(0); expect(apiFetch).toHaveBeenCalledTimes(calls); await act(async () => host.querySelector('[aria-label="미리보기 닫기"]')!.click()); @@ -231,18 +238,23 @@ it("web preview closes independently and switching pins never fetches or opens d 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 () => { +it("web specific autocomplete opens detail directly and its pin reopens detail", 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(host.querySelector('[data-search-place-preview]')).toBeNull(); + expect(host.querySelector('[aria-label="닫기"]')).not.toBeNull(); expect(views()).toHaveLength(1); await act(async () => host.querySelector('[aria-label="닫기"]')!.click()); + expect(host.querySelector('[data-search-place-preview]')).toBeNull(); const marker = [...host.querySelectorAll("[data-marker]")].find(b => b.textContent === "결과4")!; await act(async () => marker.click()); - expect(host.querySelector('[aria-label="검색 장소 미리보기"]')).toBeNull(); + expect(host.querySelector('[data-search-place-preview]')).toBeNull(); + expect(host.querySelector('[aria-label="닫기"]')).not.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 }); @@ -474,3 +486,42 @@ it("discover ownership cancels pending search without showing its anchor", async ), ).toHaveLength(0); }); + +it("selected preview label receives marker priority and transfers it to the next selection", async () => { + await render(false); + boundary.page.mockResolvedValue({ items, nextPageToken: null }); + await act(async () => { await controller.confirm({ query: "카페", kind: "whole", session: null, generation: 0 }); }); + const markers = () => [...host.querySelectorAll("[data-marker]")]; + const first = () => markers().find(el => el.textContent === "결과1")!; + const second = () => markers().find(el => el.textContent === "결과2")!; + await act(async () => first().click()); + expect(Number(first().dataset.priority)).toBeGreaterThan(Number(second().dataset.priority || 0)); + expect(host.querySelector("[data-search-place-preview]")?.textContent).toContain("결과1"); + await act(async () => second().click()); + expect(Number(second().dataset.priority)).toBeGreaterThan(Number(first().dataset.priority || 0)); + expect(host.querySelector("[data-search-place-preview]")?.textContent).toContain("결과2"); +}); + +it.each(["whole", "specific"] as const)("%s search resets discover filters without losing search ownership or results", async kind => { + await render(false); + const button = (text: string) => [...document.querySelectorAll("button")].find(b => b.textContent === text)!; + await act(async () => button("음식점").click()); + await act(async () => button("영업 중").click()); + await act(async () => host.querySelector('[aria-label="평점 필터"]')!.click()); + await act(async () => button("4.5 이상").click()); + await act(async () => { useMapPinsFocusStore.getState().claimFocus("discover"); }); + boundary.page.mockResolvedValue({ items, nextPageToken: null }); + await act(async () => { + await controller.confirm({ query: "원문", kind, generation: 0, + session: { sessionToken: "token", expiresAt: Date.now() + 60000 }, prediction: prediction("result-4") }); + }); + expect(host.querySelector('[aria-label="카테고리 필터 초기화"]')).toBeNull(); + expect(button("호텔")).toBeDefined(); + expect(useMapPinsFocusStore.getState().focus).toBe("search"); + expect(usePlaceSearchStore.getState().status).toBe("done"); + expect(useSearchMapPinsStore.getState().pins.length).toBeGreaterThan(0); + expect(host.querySelectorAll('[data-marker]').length).toBeGreaterThan(0); + await act(async () => button("음식점").click()); + expect(host.querySelector('[aria-label="평점 필터"]')?.textContent).toBe("평점"); + expect(button("영업 중").getAttribute("aria-pressed")).toBe("false"); +}); diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index 2d8e7e5d..c95ead90 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { usePathname } from "next/navigation"; import { Bookmark } from "lucide-react"; import { @@ -52,7 +52,7 @@ import { useRoomDetail } from "@/hooks/useRoomDetail"; import { useRoomsList } from "@/hooks/useRooms"; import { RouteIcon } from "@/assets/icons"; import { MapPinIcon } from "@/components/icons"; -import { MapPinWithPlaceName } from "@/components/map/MapPinWithPlaceName"; +import { MapPinWithPlaceName, MapPlaceName } from "@/components/map/MapPinWithPlaceName"; import { MapBookmarkPins } from "./MapBookmarkPins"; import { MAP_PIN_SELECTED_DISPLAY_SIZE_PX, @@ -316,10 +316,12 @@ function releaseSearchForMapSelection() { useSearchRecenterStore.getState().clearSearchSnapshot(); } -const SEARCH_PREVIEW_OFFSET: [number, number] = [0, -40]; +// Keep a small visual gap between the preview tail and the selected pin. +const SEARCH_PREVIEW_GAP_OFFSET: [number, number] = [0, -4]; function MapSearchResultPins() { const map = useMap(); + const [previewMarker, setPreviewMarker] = useState(null); const pins = useSearchMapPinsStore((s) => s.pins); const pinsFocus = useMapPinsFocusStore((s) => s.focus); const { selectedPlace, setSelectedPlace } = useSelectedPlace(); @@ -329,11 +331,15 @@ function MapSearchResultPins() { const status = usePlaceSearchStore((s) => s.status); const kind = usePlaceSearchStore((s) => s.confirmationKind); const fittedItemsRef = useRef(null); - const preview = !isMobileDevice && kind === "whole" && !selectedPlace + const preview = pinsFocus === "search" && !isMobileDevice && kind === "whole" && !selectedPlace ? items.find((item) => item.googlePlaceId === searchSelectedId) : undefined; const selectedPlaceId = isMobileDevice && !selectedPlace ? searchSelectedId ?? "" : selectedPlace?.googlePlaceId?.trim() ?? ""; + useEffect(() => { + if (map && preview) panToWithinWorld(map, preview.location); + }, [map, preview]); + // 전체 검색 결과가 준비되면 첫 추천 대신 실제 결과들이 보이도록 맞춘다. useEffect(() => { if (!map || isMobileDevice || pinsFocus !== "search" || kind !== "whole" || status !== "done" || !items.length || usePlaceSearchStore.getState().selectedId || fittedItemsRef.current === items) return; @@ -356,6 +362,9 @@ function MapSearchResultPins() { .map((pin) => ( { e.stop(); @@ -386,8 +395,8 @@ function MapSearchResultPins() { ))} {preview ? ( - { if (usePlaceSearchStore.getState().selectedId === preview.googlePlaceId) usePlaceSearchStore.getState().select(null); @@ -410,9 +419,11 @@ function MapSearchResultPins() { export default function TripMap({ routeView, onOpenRouteView, + search, }: { /** 있으면 경로 보기 — 검색·필터, 북마크·탐색 핀, 다른 일차 경로를 숨긴다 */ routeView?: MapRouteView; + search?: ReactNode; /** 있으면 지도 왼쪽 아래에 경로 보기 버튼을 둔다(모바일 지도) */ onOpenRouteView?: () => void; } = {}) { @@ -433,6 +444,14 @@ export default function TripMap({ const [openNow, setOpenNow] = useState("all"); const [showBookmarkPins, setShowBookmarkPins] = useState(true); + // Text search takes over the map at request start, including autocomplete selection. + useEffect(() => useMapPinsFocusStore.subscribe((state, previous) => { + if (state.focus !== "search" || state.writeEpoch === previous.writeEpoch) return; + setSelectedCategoryId(null); + setRating("all"); + setOpenNow("all"); + }), []); + const currentRoomId = useSessionStore((s) => s.currentRoomId); const { data: roomsData, isFetched: roomsFetched } = useRoomsList(); const { data: roomDetail, isFetched: roomDetailFetched } = @@ -647,6 +666,7 @@ export default function TripMap({ {routeView ? null : (