- {review.authorDisplayName.charAt(0)}
+ {review.authorDisplayName?.charAt(0)}
@@ -85,7 +85,7 @@ export function ReviewsTab({
{review.relativePublishTimeDescription}
-
+ {review.rating != null ?
: null}
{review.text}
diff --git a/src/components/place/types.ts b/src/components/place/types.ts
index e62edea0..182351f4 100644
--- a/src/components/place/types.ts
+++ b/src/components/place/types.ts
@@ -1,22 +1,17 @@
export const TABS = ["홈", "리뷰"] as const;
export type Tab = (typeof TABS)[number];
-export type PlaceReview = {
- rating: number;
- text: string;
- authorDisplayName: string;
- publishTime: string;
- relativePublishTimeDescription: string;
-};
+export type { PlaceReview } from "@/lib/api/places";
+import type { PlaceReview } from "@/lib/api/places";
export type PlaceDetailResult = {
- name: string;
- primaryTypeDisplayName: string;
+ name: string | null;
+ primaryTypeDisplayName: string | null;
rating: number | null;
- formattedAddress: string;
- location: { lat: number; lng: number };
- phone: string;
- websiteUri: string;
+ formattedAddress: string | null;
+ location: { lat: number; lng: number } | undefined;
+ phone: string | null;
+ websiteUri: string | null;
placeUri: string | null;
reviewsUri: string | null;
openNow: boolean | null;
diff --git a/src/components/place/usePlaceDetailData.test.ts b/src/components/place/usePlaceDetailData.test.ts
index 98fb5435..a677008f 100644
--- a/src/components/place/usePlaceDetailData.test.ts
+++ b/src/components/place/usePlaceDetailData.test.ts
@@ -1,15 +1,10 @@
-import { describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import type { PlaceDetail } from "@/lib/api/places";
import * as placeDetailDataModule from "./usePlaceDetailData";
import type { PlaceDetailResult } from "./types";
-vi.mock("@/lib/places/place-queries", () => ({
- fetchPlaceDetail: vi.fn(),
- placeDetailQueryDefaults: {},
-}));
-
type PlaceDetailDataModule = typeof placeDetailDataModule & {
toPlaceDetailResult: (detail: PlaceDetail) => PlaceDetailResult;
};
@@ -49,3 +44,14 @@ describe("toPlaceDetailResult", () => {
);
});
});
+it("nullable resolve fields remain absent without fabricated coordinates or summary", () => {
+ const result = placeDetailDataModule.toPlaceDetailResult({
+ googlePlaceId: "null", name: null, formattedAddress: null, location: { lat: null, lng: 2 },
+ primaryType: null, primaryTypeDisplayName: null, rating: null, userRatingCount: null,
+ phoneNumber: null, websiteUri: null, googleMapsUri: null, googleMapsLinks: null,
+ regularOpeningHours: null, reviews: [{ rating: null, text: null, authorDisplayName: null, publishTime: null, relativePublishTimeDescription: null }],
+ });
+ expect(result.location).toBeUndefined();
+ expect(result.reviewSummary).toBeNull();
+ expect(result.reviews[0].rating).toBeNull();
+});
diff --git a/src/components/place/usePlaceDetailData.ts b/src/components/place/usePlaceDetailData.ts
index d4b822c7..225e369f 100644
--- a/src/components/place/usePlaceDetailData.ts
+++ b/src/components/place/usePlaceDetailData.ts
@@ -1,8 +1,9 @@
import { useQuery } from "@tanstack/react-query";
-import type { PlaceDetail } from "@/lib/api/places";
+import { requestPlaceDetail, type PlaceDetail } from "@/lib/api/places";
import {
- fetchPlaceDetail,
+ placeDetailQueryKey,
+ normalizePreviewLocation,
placeDetailQueryDefaults,
} from "@/lib/places/place-queries";
import type { PlaceDetailResult } from "./types";
@@ -13,7 +14,7 @@ export function toPlaceDetailResult(detail: PlaceDetail): PlaceDetailResult {
primaryTypeDisplayName: detail.primaryTypeDisplayName,
rating: detail.rating,
formattedAddress: detail.formattedAddress,
- location: detail.location,
+ location: normalizePreviewLocation(detail.location),
phone: detail.phoneNumber,
websiteUri: detail.websiteUri,
placeUri: detail.googleMapsLinks?.placeUri ?? null,
@@ -21,19 +22,17 @@ export function toPlaceDetailResult(detail: PlaceDetail): PlaceDetailResult {
openNow: detail.regularOpeningHours?.openNow ?? null,
weekdayDescriptions: detail.regularOpeningHours?.weekdayDescriptions ?? [],
userRatingCount: detail.userRatingCount,
- reviewSummary: detail.reviewSummary,
+ reviewSummary: detail.reviewSummary ?? null,
reviews: detail.reviews ?? [],
};
}
export function usePlaceDetailData(googlePlaceId?: string) {
const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : "";
- return useQuery
({
- queryKey: ["places", "detail-panel", id],
- queryFn: async () => {
- const detail = await fetchPlaceDetail(id);
- return toPlaceDetailResult(detail);
- },
+ return useQuery({
+ queryKey: placeDetailQueryKey(id),
+ queryFn: () => requestPlaceDetail(id),
+ select: toPlaceDetailResult,
enabled: id.length > 0,
...placeDetailQueryDefaults,
});
diff --git a/src/components/rooms/RoomTripEditDialog.test.tsx b/src/components/rooms/RoomTripEditDialog.test.tsx
index 61b42f5f..127433de 100644
--- a/src/components/rooms/RoomTripEditDialog.test.tsx
+++ b/src/components/rooms/RoomTripEditDialog.test.tsx
@@ -61,13 +61,13 @@ beforeEach(async () => {
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); });
-it("opens one named modal beside the title and cancel discards its draft with focus restoration", async () => {
+it("opens one named modal beside the title and closing discards its draft with focus restoration", async () => {
const trigger = await open();
expect(trigger.parentElement?.textContent).toContain("가을 여행");
expect(document.querySelectorAll("dialog[open]")).toHaveLength(1);
expect(document.getElementById(dialog()!.getAttribute("aria-labelledby")!)?.textContent).toBe("여행 정보 수정");
await change('input[type="text"]', "임시 제목");
- await click("취소", dialog()!);
+ await click("여행 정보 수정 닫기", dialog()!);
expect(dialog()).toBeNull(); expect(document.activeElement).toBe(trigger);
await open();
expect(dialog()!.querySelector('input[type="text"]')!.value).toBe("가을 여행");
diff --git a/src/components/rooms/RoomTripEditForm.test.tsx b/src/components/rooms/RoomTripEditForm.test.tsx
index 55e427d7..375159c8 100644
--- a/src/components/rooms/RoomTripEditForm.test.tsx
+++ b/src/components/rooms/RoomTripEditForm.test.tsx
@@ -22,7 +22,7 @@ vi.mock("./TripDateShrinkConfirmModal", () => ({
TripDateShrinkConfirmModal: () =>
,
}));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
-it("preserves drafts and original date floor on same-room refresh; cancel and room switch reset correctly", async () => {
+it("preserves drafts and original date floor on same-room refresh; reopening and room switch reset correctly", async () => {
const container = document.createElement("div");
const root = createRoot(container);
const room = {
@@ -39,21 +39,23 @@ it("preserves drafts and original date floor on same-room refresh; cancel and ro
await act(async () => state.fields!.onTitleChange("draft"));
await render({ ...room, destinations: [...room.destinations] });
expect(state.fields!.values.title).toBe("draft");
- await act(async () => container.querySelectorAll("button")[0].click());
+ await act(async () => root.render(null));
+ await render(room);
expect(state.fields!.values.title).toBe("A");
await act(async () => {
state.fields!.onStartDateChange("2026-10-11");
state.fields!.onEndDateChange("2026-10-11");
});
expect(state.fields!.endDateMin).toBe("2026-10-11");
- await act(async () => container.querySelectorAll("button")[0].click());
+ await act(async () => root.render(null));
+ await render(room);
expect(state.fields!.values.startDate).toBe("2026-10-10");
expect(state.fields!.values.endDate).toBe("2026-10-12");
expect(state.fields!.startDateMin).toBe("2026-10-10");
await render({ ...room, startDate: "2026-10-11" });
expect(state.fields!.startDateMin).toBe("2026-10-10");
await act(async () => state.fields!.onEndDateChange("2026-10-11"));
- await act(async () => container.querySelectorAll("button")[1].click());
+ await act(async () => container.querySelector("button")!.click());
expect(container.querySelector("[data-confirm]")).not.toBeNull();
await render({
...room,
@@ -70,19 +72,18 @@ it("preserves drafts and original date floor on same-room refresh; cancel and ro
await act(async () => root.unmount());
});
-it("does not submit on cancel and submits once for rapid apply clicks", async () => {
+it("does not submit draft edits and submits once for rapid apply clicks", async () => {
state.update.mockClear();
const container = document.createElement("div");
const root = createRoot(container);
const room = { id: "r", title: "old", destinations: ["서울"], startDate: "2027-01-01", endDate: "2027-01-03" };
await act(async () => root.render( ));
await act(async () => state.fields!.onTitleChange("cancelled"));
- await act(async () => container.querySelectorAll("button")[0].click());
expect(state.update).not.toHaveBeenCalled();
await act(async () => state.fields!.onTitleChange("new"));
await act(async () => {
- container.querySelectorAll("button")[1].click();
- container.querySelectorAll("button")[1].click();
+ container.querySelector("button")!.click();
+ container.querySelector("button")!.click();
});
expect(state.update).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
diff --git a/src/components/search/MapMobileSearchScreen.tsx b/src/components/search/MapMobileSearchScreen.tsx
new file mode 100644
index 00000000..d3b6bb16
--- /dev/null
+++ b/src/components/search/MapMobileSearchScreen.tsx
@@ -0,0 +1,209 @@
+"use client";
+
+import { useEffect, useEffectEvent, useState, type RefObject } from "react";
+import { createPortal } from "react-dom";
+import {
+ CloseIcon,
+ LocationIcon,
+ SearchIcon,
+} from "@/assets/icons";
+import { useSessionUser } from "@/hooks/useSessionUser";
+import {
+ addMapSearchHistory,
+ mapSearchHistoryEntryKey,
+ readMapSearchHistory,
+ removeMapSearchHistory,
+ type MapSearchHistoryEntry,
+ type MapSearchPlaceEntry,
+} from "@/lib/map-search-history";
+import type { SearchConfirmation } from "@/lib/places/search-session";
+import { MapPlaceSearchInput } from "./MapPlaceSearchInput";
+import { usePlaceSearchStore } from "@/stores/place-search-store";
+import { cn } from "@/lib/utils";
+
+export type MapMobileSearchProps = {
+ open: boolean;
+ onClose: () => void;
+ inputRef: RefObject;
+ backLabel: string;
+ onConfirm: (confirmation: SearchConfirmation) => Promise;
+ onInvalidate: () => void;
+ onClear: () => void;
+ onSelectPlace: (
+ entry: MapSearchPlaceEntry,
+ ) => Promise;
+ scopeKey: string;
+ initialQuery?: string;
+ resetKey?: number;
+};
+/** Map-only confirmation path; itinerary input consumers keep the original screen contract. */
+export function MapMobileSearchScreen({
+ open,
+ onClose,
+ inputRef,
+ backLabel,
+ onConfirm,
+ onInvalidate,
+ onClear,
+ onSelectPlace,
+ scopeKey,
+ initialQuery = "",
+ resetKey = 0,
+}: MapMobileSearchProps) {
+ const error = usePlaceSearchStore((state) => state.error);
+ const userId = useSessionUser().data?.id;
+ const [query, setQuery] = useState(initialQuery);
+ const [history, setHistory] = useState([]);
+ const [picking, setPicking] = useState(false);
+ const resetInput = useEffectEvent(() => {
+ setQuery(initialQuery);
+ });
+ useEffect(() => {
+ resetInput();
+ }, [scopeKey, resetKey]);
+ const refresh = useEffectEvent(() => {
+ setHistory(readMapSearchHistory(userId));
+ });
+ useEffect(() => {
+ if (open) refresh();
+ }, [open, userId]);
+ async function confirm(confirmation: SearchConfirmation) {
+ const success = await onConfirm(confirmation);
+ if (success) {
+ const prediction =
+ confirmation.kind === "specific" ? confirmation.prediction : null;
+ setHistory(
+ addMapSearchHistory(
+ userId,
+ prediction
+ ? {
+ type: "place",
+ googlePlaceId: prediction.placeId,
+ name: prediction.structuredFormat?.mainText?.text ?? prediction.text?.text ?? "",
+ address: prediction.structuredFormat?.secondaryText?.text ?? "",
+ }
+ : { type: "query", query: confirmation.query },
+ ),
+ );
+ }
+ return success;
+ }
+ function close() {
+ onInvalidate();
+ inputRef.current?.blur();
+ onClose();
+ }
+ async function selectHistory(entry: MapSearchHistoryEntry) {
+ if (picking) return;
+ setPicking(true);
+ try {
+ if (entry.type === "query") {
+ await confirm({
+ query: entry.query,
+ kind: "whole",
+ session: null,
+ generation: 0,
+ });
+ } else {
+ const saved = await onSelectPlace(entry);
+ if (saved) setHistory(addMapSearchHistory(userId, saved));
+ }
+ } finally {
+ setPicking(false);
+ }
+ }
+ if (typeof document === "undefined") return null;
+ return createPortal(
+
+
+
+
+ {error ? (
+
+ {error}
+ inputRef.current?.focus()}
+ >
+ 다시 시도
+
+
+ ) : null}
+ {!query.trim() ? (
+
+
+ 검색 기록
+
+
+ {history.map((entry) => {
+ const Icon = entry.type === "place" ? LocationIcon : SearchIcon;
+ const title = entry.type === "place" ? entry.name : entry.query;
+ return (
+
+ void selectHistory(entry)}
+ className="flex h-full min-w-0 flex-1 items-center gap-2.5 text-left"
+ >
+
+
+
+ {title}
+
+ {entry.type === "place" && entry.address ? (
+
+ {entry.address}
+
+ ) : null}
+
+
+
+ setHistory(removeMapSearchHistory(userId, entry))
+ }
+ className="flex size-12 shrink-0 items-center justify-center"
+ >
+
+
+
+ );
+ })}
+
+
+ ) : 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 (
+
+
+ {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 (
+
+ 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"
+ >
+
+
+
+ {typeof title === "string" ? title : }
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+
+
+ );
+}
+
+/** 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 }),
+}));