+
- {place.category ?
{place.category}
: null}
- {place.rating != null ? (
-
-
-
{place.rating.toFixed(1)}
- {place.userRatingCount != null ?
({place.userRatingCount.toLocaleString()}개) : null}
+
+
+ {place.category || openingStatus ? (
+
+ {[place.category, openingStatus].filter(Boolean).join(" · ")}
+
+ ) : null}
+ {place.rating != null ? (
+
+
+ {place.rating.toFixed(1)}
+ {place.userRatingCount != null ? ({place.userRatingCount.toLocaleString()}개) : null}
+
+ ) : null}
+
+
+
- ) : null}
-
-
- {openingStatus}
-
-
);
diff --git a/src/components/map/map-chip-button.ts b/src/components/map/map-chip-button.ts
index 26b4b10e..eb7c4d93 100644
--- a/src/components/map/map-chip-button.ts
+++ b/src/components/map/map-chip-button.ts
@@ -1,38 +1,21 @@
import { cn } from "@/lib/utils";
const mapChipButtonBaseClass =
- "shrink-0 cursor-pointer rounded-full px-3 py-1.5 text-label-m-regular mobile:text-label-s-regular font-medium shadow-md transition-[background-color,color,opacity,box-shadow] duration-200";
+ "inline-flex h-9 shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full border bg-fill-elevate px-4 text-label-m-emphasis shadow-[0_2px_10px_#0000001a] transition-colors hover:bg-fill active:bg-fill-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary";
-/** 지도 필터 칩 — 미적용(전체·영업 중 off) */
export function mapFilterChipInactiveClassName() {
- return cn(
- mapChipButtonBaseClass,
- "bg-white text-gray ring-2 ring-light-gray ring-offset-1 hover:bg-gray-50 active:opacity-90",
- );
+ return cn(mapChipButtonBaseClass, "border-border text-text");
}
-/** 지도 필터 칩 — 값 선택됨(평점 등) */
export function mapFilterChipActiveRingClassName() {
- return cn(
- mapChipButtonBaseClass,
- "bg-white text-primary ring-2 ring-primary ring-offset-1 hover:bg-gray-50 active:opacity-90",
- );
+ return cn(mapChipButtonBaseClass, "border-primary-strong text-primary-strong");
}
-/** 지도 필터 칩 — 토글 on(영업 중) */
export function mapFilterChipActiveFilledClassName() {
- return cn(
- mapChipButtonBaseClass,
- "bg-primary text-white hover:opacity-95 active:opacity-90",
- );
+ return mapFilterChipActiveRingClassName();
}
-/** 기본: 앱 primary(red/white). active: 흰 배경 + red ring으로 선택 구분 */
+/** Chip / Outline / md, including the selected category. */
export function mapChipButtonClassName(active: boolean) {
- return cn(
- mapChipButtonBaseClass,
- active
- ? "bg-white text-primary ring-2 ring-primary ring-offset-1 hover:bg-gray-50 active:opacity-90"
- : "bg-primary text-white hover:opacity-95 active:opacity-90",
- );
+ return active ? mapFilterChipActiveRingClassName() : mapFilterChipInactiveClassName();
}
diff --git a/src/components/map/map-place-categories.ts b/src/components/map/map-place-categories.ts
index 7bcd235e..c51cae17 100644
--- a/src/components/map/map-place-categories.ts
+++ b/src/components/map/map-place-categories.ts
@@ -1,11 +1,5 @@
-import type { LucideIcon } from "lucide-react";
-import {
- UtensilsCrossed,
- BedDouble,
- Camera,
- Landmark,
- TrainFront,
-} from "lucide-react";
+import type { ComponentProps } from "react";
+import type { MapSearchIcon } from "./MapSearchIcon";
/**
* Google Maps Place types (Table A).
@@ -17,7 +11,7 @@ export type MapPlaceCategory = {
label: string;
/** 레거시/검색 문맥용 쿼리 문자열 */
searchQuery: string;
- icon: LucideIcon;
+ icon: ComponentProps
["name"];
/** Nearby Search `includedPrimaryTypes` */
googlePlaceTypeHint: string;
};
@@ -27,35 +21,35 @@ export const MAP_PLACE_CATEGORIES: MapPlaceCategory[] = [
id: "restaurant",
label: "음식점",
searchQuery: "음식점",
- icon: UtensilsCrossed,
+ icon: "restaurant",
googlePlaceTypeHint: "restaurant",
},
{
id: "lodging",
label: "호텔",
searchQuery: "호텔",
- icon: BedDouble,
+ icon: "hotel",
googlePlaceTypeHint: "lodging",
},
{
id: "things_to_do",
- label: "즐길 거리",
+ label: "즐길거리",
searchQuery: "관광명소 즐길거리",
- icon: Camera,
+ icon: "attraction",
googlePlaceTypeHint: "tourist_attraction",
},
{
id: "museum",
label: "박물관",
searchQuery: "박물관",
- icon: Landmark,
+ icon: "museum",
googlePlaceTypeHint: "museum",
},
{
id: "transit",
label: "대중교통",
searchQuery: "지하철역 버스정류장",
- icon: TrainFront,
+ icon: "transit",
googlePlaceTypeHint: "transit_station",
},
];
diff --git a/src/components/map/useMapPlaceSearch.test.tsx b/src/components/map/useMapPlaceSearch.test.tsx
index 77a8a364..0eb27186 100644
--- a/src/components/map/useMapPlaceSearch.test.tsx
+++ b/src/components/map/useMapPlaceSearch.test.tsx
@@ -10,6 +10,7 @@ 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 { useMapPinsFocusStore } from "@/stores/map-pins-focus-store";
import { useSessionStore } from "@/stores/session-store";
const network = vi.hoisted(() => ({
page: vi.fn(),
@@ -43,7 +44,7 @@ function Harness({ mobile = false }: { mobile?: boolean }) {
);
}
@@ -250,3 +251,29 @@ it("cache HIT never falsely marks termination completed", async () => {
await act(async () => { await api.confirm(confirmation("specific")); });
expect(usePlaceSearchStore.getState().termination).toBe("NOT_TERMINATED");
});
+
+it("desktop specific selection opens the chosen place detail directly", async () => {
+ await act(async () => { await api.confirm(confirmation("specific")); });
+ expect(usePlaceSearchStore.getState().selectedId).toBe("first");
+ expect(usePlaceSearchStore.getState().items[0].name).toBe("추천");
+ expect(network.select).toHaveBeenLastCalledWith(expect.objectContaining({googlePlaceId:"first",name:"추천"}), expect.objectContaining({itinerarySource:"search",analyticsSource:"search"}));
+ expect(network.page).not.toHaveBeenCalled();
+});
+
+ it("switching to discover clears text and ignores an in-flight text response", async () => {
+ let resolve!: (value: {items: unknown[]; nextPageToken: null}) => void;
+ network.page.mockReturnValue(new Promise(done => { resolve = done; }));
+ await act(async () => {
+ const input = host.querySelector("input")!;
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,"value")!.set!.call(input,"원래 검색어");
+ input.dispatchEvent(new Event("input",{bubbles:true}));
+ });
+ let pending!: Promise;
+ await act(async () => { pending = api.confirm(confirmation()); });
+ await act(async () => { useMapPinsFocusStore.getState().claimFocus("discover"); });
+ expect(usePlaceSearchStore.getState()).toMatchObject({query:"",items:[],selectedId:null,status:"idle"});
+ expect(host.querySelector("input")!.value).toBe("");
+ await act(async () => { resolve({items:[],nextPageToken:null}); await pending; });
+ expect(useMapPinsFocusStore.getState().focus).toBe("discover");
+ expect(usePlaceSearchStore.getState()).toMatchObject({query:"",items:[],status:"idle"});
+});
diff --git a/src/components/map/useMapPlaceSearch.ts b/src/components/map/useMapPlaceSearch.ts
index 807625ef..85b22916 100644
--- a/src/components/map/useMapPlaceSearch.ts
+++ b/src/components/map/useMapPlaceSearch.ts
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useEffectEvent, useRef } from "react";
+import { useEffect, useEffectEvent, useRef, useState } from "react";
import { useSelectedPlace } from "@/contexts/SelectedPlaceContext";
import { useSessionUser } from "@/hooks/useSessionUser";
import {
@@ -48,6 +48,7 @@ export function useMapPlaceSearch(mobile: boolean) {
const legacyNonce = useMobileMapSearchStore((s) => s.textSearchNonce);
const seenLegacyNonce = useRef(legacyNonce);
const seenRecenter = useRef(recenterId);
+ const [inputResetKey, setInputResetKey] = useState(0);
useEffect(() => {
if (usePlaceSearchStore.getState().scope !== scopeKey) {
@@ -66,6 +67,16 @@ export function useMapPlaceSearch(mobile: boolean) {
setSelectedPlace(null);
}
+ const switchToDiscover = useEffectEvent(() => {
+ clearSearch();
+ useMobileMapSearchStore.getState().clearTextSearch();
+ setInputResetKey((key) => key + 1);
+ });
+ useEffect(() => useMapPinsFocusStore.subscribe((state, previous) => {
+ if (state.focus === "discover" && state.writeEpoch !== previous.writeEpoch)
+ switchToDiscover();
+ }), []);
+
function invalidate() {
const selectedId = usePlaceSearchStore.getState().selectedId;
// Keep complete visible data, but unmount an unfinished detail selection so its late
@@ -242,7 +253,7 @@ export function useMapPlaceSearch(mobile: boolean) {
}
}, [legacyNonce, legacyQuery]);
- return { confirm, invalidate, clearSearch, scopeKey };
+ return { confirm, invalidate, clearSearch, scopeKey, inputResetKey };
}
function toAnchor(
diff --git a/src/components/search/MapPlaceSearchInput.test.tsx b/src/components/search/MapPlaceSearchInput.test.tsx
index b8e14e7f..1e7c4a23 100644
--- a/src/components/search/MapPlaceSearchInput.test.tsx
+++ b/src/components/search/MapPlaceSearchInput.test.tsx
@@ -8,6 +8,7 @@ import { useMapCenterStore } from "@/stores/map-center-store";
vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() }));
const sdk = { fetch: vi.mocked(apiFetch) };
let issued = 0;
+let testTime = Date.now();
function autocompleteCalls() { return sdk.fetch.mock.calls.filter(([url]) => String(url).endsWith("/places/autocomplete")); }
let root: Root, host: HTMLDivElement;
const confirm = vi.fn(),
@@ -20,6 +21,8 @@ const p = {
beforeEach(async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
vi.useFakeTimers();
+ // Keep fake time monotonic so the real shared Retry-After bucket cannot leak into the next test.
+ vi.setSystemTime(testTime += 3600000);
issued = 0;
useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 } });
sdk.fetch.mockReset().mockImplementation(async (url) => String(url).endsWith("/places/sessions")
@@ -173,12 +176,12 @@ it("nullable structured main text falls back to actual complete prediction text"
expect(host.querySelectorAll('[role="option"]')[1]?.textContent).toContain("전체 실제 이름");
});
-it.each(["whole", "specific"])("clears successful desktop %s input without invalidating published results", async kind => {
+it.each(["whole", "specific"])("retains 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("input")!.value).toBe(kind === "specific" ? "추천" : "원래");
expect(host.querySelector('[role="listbox"]')).toBeNull();
expect(invalidate).toHaveBeenCalledTimes(invalidations);
});
@@ -193,3 +196,65 @@ it("retains failed input for retry and never clears a newer draft after a late s
await act(async () => finish(true));
expect(host.querySelector("input")!.value).toBe("새 입력");
});
+
+it("opens from an icon with focus, clears Filled to an expanded Default field, and Escape restores the icon", async () => {
+ const clear = vi.fn();
+ await act(async () => root.render());
+ // A fresh scope resets even an already-mounted search to its icon state.
+ await act(async () => root.render());
+ expect(host.querySelector("input")).toBeNull();
+ await act(async () => (host.querySelector('button[aria-label="장소 검색 열기"]') as HTMLButtonElement).click());
+ expect(document.activeElement).toBe(host.querySelector("input"));
+ await type("원래"); await wait();
+ await key("Enter");
+ await act(async () => (host.querySelector('button[aria-label="검색어 지우기"]') as HTMLButtonElement).click());
+ expect(host.querySelector("input")!.value).toBe("");
+ expect(host.querySelector('[aria-label="장소 검색 열기"]')).toBeNull();
+ expect(host.querySelector('[role="listbox"]')).toBeNull();
+ expect(clear).toHaveBeenCalledOnce();
+ expect(document.activeElement).not.toBe(host.querySelector("input"));
+ await act(async () => document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })));
+ expect(host.querySelector("input")).toBeNull();
+ await act(async () => host.querySelector('[aria-label="장소 검색 열기"]')!.click());
+ await key("Escape");
+ expect(host.querySelector("input")).toBeNull();
+ expect(document.activeElement).toBe(host.querySelector('button[aria-label="장소 검색 열기"]'));
+});
+
+ it("collapses empty desktop search on outside blur but preserves typed text and recommendation selection", async () => {
+ await act(async () => root.render());
+ const outside = document.createElement("button");
+ document.body.append(outside);
+ try {
+ await act(async () => host.querySelector('[aria-label="장소 검색 열기"]')!.click());
+ await act(async () => outside.focus());
+ expect(host.querySelector("input")).toBeNull();
+ expect(document.activeElement).toBe(outside);
+ await act(async () => host.querySelector('[aria-label="장소 검색 열기"]')!.click());
+ await type("원래"); await wait();
+ await act(async () => outside.focus());
+ expect(host.querySelector("input")!.value).toBe("원래");
+ await act(async () => host.querySelector("input")!.focus());
+ await wait();
+ await act(async () => host.querySelectorAll('[role="option"] button')[1].click());
+ expect(confirm).toHaveBeenCalledWith(expect.objectContaining({kind:"specific"}));
+ expect(host.querySelector("input")!.value).toBe("추천");
+ } finally { outside.remove(); }
+});
+
+it("uses one active suggestion when switching between keyboard and mouse", async () => {
+ await type("원래"); await wait();
+ await key("ArrowDown");
+ expect(host.querySelector('[aria-selected="true"]')?.textContent).toContain("모든 결과보기");
+ const recommendation = host.querySelectorAll('[role="option"] button')[1];
+ await act(async () => recommendation.dispatchEvent(new MouseEvent("mousemove", { bubbles: true })));
+ expect(host.querySelectorAll('[aria-selected="true"]')).toHaveLength(1);
+ expect(host.querySelector('[aria-selected="true"]')?.textContent).toContain("추천");
+ expect(host.querySelector("input")!.getAttribute("aria-activedescendant")).toBe(host.querySelector('[aria-selected="true"]')!.id);
+ await key("ArrowUp");
+ expect(host.querySelectorAll('[aria-selected="true"]')).toHaveLength(1);
+ expect(host.querySelector('[aria-selected="true"]')?.textContent).toContain("모든 결과보기");
+ await act(async () => recommendation.dispatchEvent(new MouseEvent("mousemove", { bubbles: true })));
+ await key("Enter");
+ expect(confirm).toHaveBeenCalledWith(expect.objectContaining({kind:"specific", prediction:p}));
+});
diff --git a/src/components/search/MapPlaceSearchInput.tsx b/src/components/search/MapPlaceSearchInput.tsx
index 38ac7da1..cb688dd8 100644
--- a/src/components/search/MapPlaceSearchInput.tsx
+++ b/src/components/search/MapPlaceSearchInput.tsx
@@ -8,7 +8,9 @@ import {
useState,
type RefObject,
} from "react";
-import { ChevronLeftIcon, CircleCancelIcon, LocationIcon, SearchIcon } from "@/assets/icons";
+import { flushSync } from "react-dom";
+import { MapSearchIcon } from "@/components/map/MapSearchIcon";
+import { ChevronLeftIcon, CircleCancelIcon, 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";
@@ -22,6 +24,7 @@ export type MapPlaceSearchInputProps = Readonly<{
scopeKey: string;
inputRef?: RefObject;
mobile?: boolean;
+ collapsible?: boolean;
initialQuery?: string;
active?: boolean;
autoConfirm?: boolean;
@@ -43,16 +46,16 @@ const INPUT_STYLES = {
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",
+ clear: "h-full w-8",
iconSize: 20,
},
desktop: {
- container: "w-[280px] max-w-[calc(100vw-32px)]",
+ container: "w-[353px] max-w-full",
form: undefined,
- field: "h-9 gap-1.5 rounded-md px-2.5",
- text: "text-body-s-regular",
- clear: "w-6",
- iconSize: 16,
+ field: "h-12 gap-2 rounded-lg pl-3.5 pr-2.5",
+ text: "text-body-m-regular",
+ clear: "size-8",
+ iconSize: 20,
},
} as const;
@@ -64,6 +67,7 @@ export function MapPlaceSearchInput({
scopeKey,
inputRef,
mobile = false,
+ collapsible = false,
initialQuery = "",
active: enabled = true,
autoConfirm = false,
@@ -72,6 +76,8 @@ export function MapPlaceSearchInput({
backLabel = "지도로 돌아가기",
}: MapPlaceSearchInputProps) {
const mapCenter = useMapCenterStore((s) => s.mapCenter);
+ const [expanded, setExpanded] = useState(!collapsible || Boolean(initialQuery));
+ const triggerRef = useRef(null);
const [value, setValue] = useState(initialQuery);
const [suggestions, setSuggestions] = useState(null);
const [active, setActive] = useState(-1);
@@ -88,6 +94,17 @@ export function MapPlaceSearchInput({
const containerRef = useRef(null);
const currentScope = useRef(scopeKey);
+ // Default can be unfocused after clearing Filled, so blur alone cannot dismiss it.
+ useEffect(() => {
+ if (!collapsible || mobile || !expanded || value.trim()) return;
+ const dismissOutside = (event: PointerEvent) => {
+ if (event.target instanceof Node && !containerRef.current?.contains(event.target))
+ setExpanded(false);
+ };
+ document.addEventListener("pointerdown", dismissOutside);
+ return () => document.removeEventListener("pointerdown", dismissOutside);
+ }, [collapsible, mobile, expanded, value]);
+
function cancel() {
generation.current += 1;
if (timer.current) clearTimeout(timer.current);
@@ -99,6 +116,7 @@ export function MapPlaceSearchInput({
token.current = null;
confirmGeneration.current = null;
setValue("");
+ setExpanded(!collapsible);
setSuggestions(null);
setActive(-1);
setOpen(false);
@@ -195,12 +213,15 @@ export function MapPlaceSearchInput({
generation: requestGeneration,
});
if (success && !mobile && generation.current === requestGeneration) {
- // 입력창만 비운다. update("")는 이미 표시한 검색 결과까지 지우므로 호출하지 않는다.
- setValue("");
+ // 결과는 유지하고 선택한 추천 이름을 Filled 상태로 보여준다.
+ const confirmedValue = index > 0
+ ? prediction?.structuredFormat?.mainText?.text ?? prediction?.text?.text ?? value
+ : value;
+ setValue(confirmedValue);
setSuggestions(null);
setActive(-1);
autoQuery.current = null;
- onQueryChange?.("");
+ onQueryChange?.(confirmedValue);
}
} finally {
if (generation.current === requestGeneration) {
@@ -225,6 +246,16 @@ export function MapPlaceSearchInput({
const rows = suggestions?.query === value ? suggestions.items : [];
const showDropdown = open && Boolean(value.trim());
const appearance = INPUT_STYLES[mobile ? "mobile" : "desktop"];
+ if (collapsible && !expanded) return (
+
+ );
return (
- ) : }
+ ) : mobile ? : }
{
if (event.nativeEvent.isComposing) return;
@@ -307,12 +339,16 @@ export function MapPlaceSearchInput({
setSuggestions(null);
onInvalidate();
setOpen(false);
+ if (collapsible && !value.trim()) {
+ flushSync(() => setExpanded(false));
+ triggerRef.current?.focus();
+ }
}
}}
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",
+ "peer min-w-0 flex-1 bg-transparent pr-1 text-text outline-none placeholder:text-text-subtle [&::-webkit-search-cancel-button]:hidden",
appearance.text,
)}
/>
@@ -322,21 +358,22 @@ export function MapPlaceSearchInput({
aria-label="검색어 지우기"
onClick={() => {
update("");
- fieldRef.current?.focus();
+ if (mobile) fieldRef.current?.focus();
+ else fieldRef.current?.blur();
}}
- className={cn("flex h-full shrink-0 cursor-pointer items-center justify-center rounded hover:bg-fill focus-visible:outline-2 focus-visible:outline-primary", appearance.clear)}
+ className={cn("flex shrink-0 cursor-pointer items-center justify-center rounded hover:bg-fill focus-visible:outline-2 focus-visible:outline-primary", appearance.clear, !mobile && "peer-focus:hidden")}
>
-
+ {mobile ? : }
) : null}
{showDropdown ? : null}
+ suggestions={suggestions} mobile={mobile} onConfirm={confirm} onActiveChange={setActive} /> : null}
);
}
-function SearchSuggestions({ listId, active, value, rows, suggestions, mobile, onConfirm }: Readonly<{
+function SearchSuggestions({ listId, active, value, rows, suggestions, mobile, onConfirm, onActiveChange }: Readonly<{
listId: string;
active: number;
value: string;
@@ -344,8 +381,9 @@ function SearchSuggestions({ listId, active, value, rows, suggestions, mobile, o
suggestions: Suggestions | null;
mobile: boolean;
onConfirm: (index: number) => Promise
;
+ onActiveChange: (index: number) => void;
}>) {
- const iconSize = mobile ? 20 : 16;
+ const iconSize = 20;
// 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 (
@@ -357,7 +395,7 @@ function SearchSuggestions({ listId, active, value, rows, suggestions, mobile, o
"bg-fill-subtle",
mobile
? "min-h-0 overflow-y-auto overscroll-contain"
- : "absolute inset-x-0 top-full mt-2 max-h-[calc(100dvh-96px)] overflow-y-auto rounded-lg border border-border-subtle",
+ : "absolute inset-x-0 top-full mt-1 max-h-[calc(100dvh-96px)] overflow-y-auto rounded-lg border border-border",
)}
>
void onConfirm(0)}
+ onMouseMove={() => onActiveChange(0)}
/>
{rows.map((prediction, index) => (
void onConfirm(index + 1)}
+ onMouseMove={() => onActiveChange(index + 1)}
place
/>
))}
@@ -398,6 +438,7 @@ function SuggestionRow({
subtitle,
active,
onClick,
+ onMouseMove,
place = false,
iconSize,
}: Readonly<{
@@ -406,10 +447,10 @@ function SuggestionRow({
subtitle?: string;
active: boolean;
onClick: () => void;
+ onMouseMove: () => void;
place?: boolean;
iconSize: number;
}>) {
- const Icon = place ? LocationIcon : SearchIcon;
return (
event.preventDefault()}
onClick={onClick}
- className="flex h-full w-full cursor-pointer items-center gap-2.5 pl-3.5 pr-4 text-left transition-colors hover:bg-fill active:bg-fill-strong focus-visible:bg-fill focus-visible:outline-none"
+ onMouseMove={onMouseMove}
+ className="flex h-full w-full cursor-pointer items-center gap-2.5 pl-3.5 pr-4 text-left transition-colors active:bg-fill-strong focus-visible:outline-none"
>
-
+
{typeof title === "string" ? title : }
diff --git a/src/hooks/usePlacePhotoUrl.test.ts b/src/hooks/usePlacePhotoUrl.test.ts
index b67ccb26..9e46a867 100644
--- a/src/hooks/usePlacePhotoUrl.test.ts
+++ b/src/hooks/usePlacePhotoUrl.test.ts
@@ -51,6 +51,30 @@ beforeEach(() => {
});
describe("usePlacePhotoUrlQuery", () => {
+ it("requests a cold single photo directly and reuses the shared cache", async () => {
+ const queryClient = new QueryClient();
+ registerQueryClient(queryClient);
+ requestPlacePhotoUrlsBatchMock.mockResolvedValue([]);
+ requestPlacePhotoUrlMock.mockResolvedValue("https://cdn.example/single.jpg");
+ usePlacePhotoUrlQuery(" ChIJ-single ");
+ const options = useQueryMock.mock.lastCall![0];
+ await expect(queryClient.fetchQuery(options)).resolves.toBe("https://cdn.example/single.jpg");
+ await expect(queryClient.fetchQuery(options)).resolves.toBe("https://cdn.example/single.jpg");
+ expect(requestPlacePhotoUrlMock).toHaveBeenCalledExactlyOnceWith("ChIJ-single");
+ expect(requestPlacePhotoUrlsBatchMock).not.toHaveBeenCalled();
+ });
+
+ it("reuses a batch-seeded photo without issuing a single request", async () => {
+ const queryClient = new QueryClient();
+ registerQueryClient(queryClient);
+ requestPlacePhotoUrlsBatchMock.mockResolvedValue([{status:"OK",googlePlaceId:"ChIJ-seeded",photoUrl:"https://cdn.example/seeded.jpg"}]);
+ await fetchAndSeedPlacePhotoUrls(["ChIJ-seeded"],queryClient);
+ usePlacePhotoUrlQuery("ChIJ-seeded");
+ await expect(queryClient.fetchQuery(useQueryMock.mock.lastCall![0])).resolves.toBe("https://cdn.example/seeded.jpg");
+ expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled();
+ expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(1);
+ });
+
it("authoritative empty 항목만 캐시하고 per-item FAILED는 cache miss로 남긴다", async () => {
const queryClient = new QueryClient();
registerQueryClient(queryClient);
@@ -115,17 +139,10 @@ describe("usePlacePhotoUrlQuery", () => {
);
});
- it("FAILED 뒤 단건 GET 재시도에서는 batch 요청을 증폭하지 않는다", async () => {
+ it("단건 GET 실패 재시도에서는 batch 요청을 보내지 않는다", async () => {
const queryClient = new QueryClient();
registerQueryClient(queryClient);
const failedId = "ChIJ-single-retry";
- requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([
- {
- status: "FAILED",
- googlePlaceId: failedId,
- errorCode: "UPSTREAM_TIMEOUT",
- },
- ]);
requestPlacePhotoUrlMock
.mockRejectedValueOnce(new Error("single request failed once"))
.mockRejectedValueOnce(new Error("single request failed twice"))
@@ -137,7 +154,7 @@ describe("usePlacePhotoUrlQuery", () => {
await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe(
"https://cdn.example/retried.jpg",
);
- expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(1);
+ expect(requestPlacePhotoUrlsBatchMock).not.toHaveBeenCalled();
expect(requestPlacePhotoUrlMock).toHaveBeenCalledTimes(3);
});
@@ -213,42 +230,25 @@ describe("usePlacePhotoUrlQuery", () => {
}
});
- it("명시적 no-photo 결과는 5분만 캐시한 뒤 batch로 다시 조회한다", async () => {
+ it("single no-photo is cached for five minutes then retried with a single GET", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-02T00:00:00.000Z"));
-
try {
const queryClient = new QueryClient();
registerQueryClient(queryClient);
const id = "ChIJ-no-photo";
- requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([
- { status: "OK", googlePlaceId: id, photoUrl: null },
- ]);
-
+ requestPlacePhotoUrlMock.mockResolvedValueOnce("").mockResolvedValueOnce("https://cdn.example/available-later.jpg");
usePlacePhotoUrlQuery(id);
- const queryOptions = useQueryMock.mock.lastCall?.[0];
-
- await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe("");
- expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled();
- expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(1);
-
- vi.advanceTimersByTime(5 * 60 * 1000 + 1);
- requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([
- {
- status: "OK",
- googlePlaceId: id,
- photoUrl: "https://cdn.example/available-later.jpg",
- },
- ]);
-
- await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe(
- "https://cdn.example/available-later.jpg",
- );
- expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(2);
- expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled();
- } finally {
- vi.useRealTimers();
- }
+ const options = useQueryMock.mock.lastCall![0];
+ await expect(queryClient.fetchQuery(options)).resolves.toBe("");
+ vi.advanceTimersByTime(4 * 60 * 1000);
+ await expect(queryClient.fetchQuery(options)).resolves.toBe("");
+ expect(requestPlacePhotoUrlMock).toHaveBeenCalledTimes(1);
+ vi.advanceTimersByTime(60 * 1000 + 1);
+ await expect(queryClient.fetchQuery(options)).resolves.toBe("https://cdn.example/available-later.jpg");
+ expect(requestPlacePhotoUrlMock).toHaveBeenCalledTimes(2);
+ expect(requestPlacePhotoUrlsBatchMock).not.toHaveBeenCalled();
+ } finally { vi.useRealTimers(); }
});
it("만료된 no-photo 뒤 batch FAILED면 낡은 빈 값을 쓰지 않고 단건 fallback한다", async () => {
@@ -274,11 +274,13 @@ describe("usePlacePhotoUrlQuery", () => {
"https://cdn.example/single-fallback.jpg",
);
+ await fetchAndSeedPlacePhotoUrls([id], queryClient);
usePlacePhotoUrlQuery(id);
const queryOptions = useQueryMock.mock.lastCall?.[0];
await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe("");
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
+ await fetchAndSeedPlacePhotoUrls([id], queryClient);
await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe(
"https://cdn.example/single-fallback.jpg",
diff --git a/src/hooks/usePlacePhotoUrl.ts b/src/hooks/usePlacePhotoUrl.ts
index a78afae8..47467def 100644
--- a/src/hooks/usePlacePhotoUrl.ts
+++ b/src/hooks/usePlacePhotoUrl.ts
@@ -4,7 +4,6 @@ import { useQueries, useQuery } from "@tanstack/react-query";
import { requestPlacePhotoUrl } from "@/lib/api/places";
import { fetchAndSeedPlacePhotoUrls } from "@/lib/places/place-batch-cache";
import {
- PLACE_PHOTO_EMPTY_STALE_MS,
PLACE_PHOTO_QUERY_GC_MS,
placePhotoUrlQueryDefaults,
placePhotoUrlQueryKey,
@@ -20,33 +19,7 @@ export function usePlacePhotoUrlQuery(
const queryKey = placePhotoUrlQueryKey(id);
return useQuery({
queryKey,
- queryFn: async () => {
- const queryClient = getQueryClient();
- if (!queryClient) {
- return requestPlacePhotoUrl(id);
- }
- const cached = queryClient.getQueryData(queryKey);
- if (typeof cached === "string" && cached.trim().length > 0) {
- return cached.trim();
- }
- const stateBeforeFetch = queryClient.getQueryState(queryKey);
- if ((stateBeforeFetch?.fetchFailureCount ?? 0) === 0) {
- await fetchAndSeedPlacePhotoUrls([id], queryClient);
- }
- const seededState = queryClient.getQueryState(queryKey);
- const seeded = seededState?.data;
- if (typeof seeded === "string") {
- const trimmed = seeded.trim();
- if (
- trimmed.length > 0 ||
- Date.now() - (seededState?.dataUpdatedAt ?? 0) <
- PLACE_PHOTO_EMPTY_STALE_MS
- ) {
- return trimmed;
- }
- }
- return requestPlacePhotoUrl(id);
- },
+ queryFn: () => requestPlacePhotoUrl(id),
enabled: id.length > 0 && (options?.enabled ?? true),
...placePhotoUrlQueryDefaults,
});