- {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/InviteRoomDialog.tsx b/src/components/rooms/InviteRoomDialog.tsx
new file mode 100644
index 00000000..3c91d4ec
--- /dev/null
+++ b/src/components/rooms/InviteRoomDialog.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { AddMemberPanel } from "@/app/(main)/member-settings/_components/AddMemberPanel";
+import { SettingsDialog } from "@/components/settings/SettingsDialog";
+
+/** 초대하기 모달 — PC 헤더와 홈 방 카드 메뉴가 같이 쓴다. 불러오기·오류·권한 안내는 `AddMemberPanel`이 맡는다 */
+export function InviteRoomDialog({
+ roomId,
+ inviteCode,
+ memberCount,
+ role,
+ isRoomDetailLoading,
+ isRoomDetailError,
+ onRetryRoomDetail,
+ onClose,
+}: Readonly<{
+ roomId: string;
+ inviteCode: string | null | undefined;
+ memberCount?: number;
+ role?: string;
+ isRoomDetailLoading: boolean;
+ isRoomDetailError: boolean;
+ onRetryRoomDetail?: () => void;
+ onClose: () => void;
+}>) {
+ return (
+
+ 링크를 공유해 함께 여행을 계획해 보세요.
+
+
+ {isRoomDetailError && onRetryRoomDetail ? (
+
+ 다시 시도
+
+ ) : null}
+
+
+ );
+}
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/RoomTripEditDialog.tsx b/src/components/rooms/RoomTripEditDialog.tsx
index 164468c5..59025560 100644
--- a/src/components/rooms/RoomTripEditDialog.tsx
+++ b/src/components/rooms/RoomTripEditDialog.tsx
@@ -3,14 +3,18 @@
import {
RoomTripDeleteButton,
RoomTripSettingsSection,
-} from "@/app/(main)/room-settings/_components/RoomTripSettingsSection";
+} from "@/components/rooms/RoomTripSettingsSection";
import { SettingsDialog } from "@/components/settings/SettingsDialog";
export function RoomTripEditDialog({ onClose }: { onClose: () => void }) {
return (
- }>
+
-
+
+
+ {/* 여행 삭제는 적용하기 아래 모달 오른쪽 끝 — 방장이 아니면 버튼이 없어 줄째 숨긴다 */}
+
+
);
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/rooms/RoomTripEditForm.tsx b/src/components/rooms/RoomTripEditForm.tsx
index 2d82f4c5..0131f340 100644
--- a/src/components/rooms/RoomTripEditForm.tsx
+++ b/src/components/rooms/RoomTripEditForm.tsx
@@ -28,7 +28,6 @@ import {
type Props = {
room: RoomTripFormSource;
readOnly?: boolean;
- onCancel?: () => void;
onSaved?: () => void;
};
@@ -36,7 +35,7 @@ export function RoomTripEditForm(props: Props) {
return ;
}
-function RoomTripEditSession({ room, readOnly = false, onCancel, onSaved }: Props) {
+function RoomTripEditSession({ room, readOnly = false, onSaved }: Props) {
const saved = toTripFormValues(room);
const [title, setTitle] = useState(saved.title);
@@ -124,16 +123,6 @@ function RoomTripEditSession({ room, readOnly = false, onCancel, onSaved }: Prop
onSaved,
]);
- function handleCancel() {
- const next = toTripFormValues(room);
- setTitle(next.title);
- setDestinations(next.destinations);
- setStartDate(next.startDate);
- setEndDate(next.endDate);
- setShrinkConfirmOpen(false);
- onCancel?.();
- }
-
function handleApply() {
if (!canApply) return;
@@ -176,15 +165,7 @@ function RoomTripEditSession({ room, readOnly = false, onCancel, onSaved }: Prop
)}
{!readOnly && (
-
-
- 취소
-
+
setDeleteOpen(true)}
className="shrink-0 cursor-pointer rounded-md px-1.5 py-0.5 text-label-s-regular text-status-negative underline underline-offset-2 transition-colors hover:bg-status-negative/5"
>
@@ -43,15 +41,7 @@ export function RoomTripDeleteButton() {
);
}
-export function RoomTripSettingsSection({
- embedded = false,
- onCancel,
- onSaved,
-}: {
- embedded?: boolean;
- onCancel?: () => void;
- onSaved?: () => void;
-}) {
+export function RoomTripSettingsSection({ onSaved }: { onSaved?: () => void }) {
const { roomId, roomSource, isLoading } = useCurrentRoomMembership();
const canEdit = isRoomMemberRole(roomSource?.role);
const description = !isLoading && roomSource && roomId && !canEdit
@@ -60,11 +50,7 @@ export function RoomTripSettingsSection({
return (
- {embedded ? (
- description ?
{description}
: null
- ) : (
-
} />
- )}
+ {description ?
{description}
: null}
{isLoading ? (
@@ -75,7 +61,7 @@ export function RoomTripSettingsSection({
여행 정보를 불러오지 못했어요.
) : (
-
+
)}
);
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/components/settings/SettingsActionButton.tsx b/src/components/settings/SettingsActionButton.tsx
index 981e4eeb..cf17ba5a 100644
--- a/src/components/settings/SettingsActionButton.tsx
+++ b/src/components/settings/SettingsActionButton.tsx
@@ -4,7 +4,7 @@ import { type ButtonHTMLAttributes, type ReactNode } from "react";
import { cn } from "@/lib/utils";
-/** room-settings `RoomTripEditForm` 하단 액션 버튼과 동일 */
+/** `RoomTripEditForm` 하단 액션 버튼과 동일 */
export const settingsActionButtonVariantClass = {
secondary:
"rounded-full border border-gray-border bg-transparent py-2.5 text-label-l-emphasis mobile:text-label-m-emphasis font-semibold text-dark-gray transition hover:bg-bubble-gray disabled:cursor-not-allowed disabled:opacity-40",
diff --git a/src/contexts/MainChromeLayoutWidthContext.tsx b/src/contexts/MainChromeLayoutWidthContext.tsx
index 7f6f381f..d3d8600f 100644
--- a/src/contexts/MainChromeLayoutWidthContext.tsx
+++ b/src/contexts/MainChromeLayoutWidthContext.tsx
@@ -27,7 +27,7 @@ export function MainChromeLayoutWidthProvider({
children: ReactNode;
}) {
const pathname = usePathname();
- const { maxWidth: contentWidthToken } = useSectionWidth();
+ const { maxWidth: contentWidthToken, sideDetailWidthPx } = useSectionWidth();
const { isMobileDevice } = useMobileView();
const targetMaxWidthPx = useMemo(
@@ -46,10 +46,12 @@ export function MainChromeLayoutWidthProvider({
targetMaxWidthPx,
isMobile: isMobileDevice,
}),
- leftSectionAnimateMinWidth: isMobileDevice ? 0 : resolveDesktopLeftSectionMinWidthPx(pathname),
+ leftSectionAnimateMinWidth: isMobileDevice
+ ? 0
+ : resolveDesktopLeftSectionMinWidthPx(pathname, sideDetailWidthPx),
layoutTransition: MAIN_LAYOUT_WIDTH_TRANSITION,
}),
- [targetMaxWidthPx, isMobileDevice, pathname],
+ [targetMaxWidthPx, isMobileDevice, pathname, sideDetailWidthPx],
);
return (
diff --git a/src/contexts/SectionWidthContext.tsx b/src/contexts/SectionWidthContext.tsx
index 6fd46fd2..fe2fddde 100644
--- a/src/contexts/SectionWidthContext.tsx
+++ b/src/contexts/SectionWidthContext.tsx
@@ -3,6 +3,7 @@
import {
createContext,
useContext,
+ useMemo,
useState,
useEffect,
type ReactNode,
@@ -14,17 +15,27 @@ type WidthToken = keyof typeof width;
type SectionWidthContextType = {
maxWidth: string;
setMaxWidth: (width: string) => void;
+ /** 패널 오른쪽에 붙는 상세 패널 폭(px) — 데스크톱 최소 폭에 더한다(준비물 상세) */
+ sideDetailWidthPx: number;
+ setSideDetailWidthPx: (px: number) => void;
};
const SectionWidthContext = createContext({
maxWidth: "",
setMaxWidth: () => {},
+ sideDetailWidthPx: 0,
+ setSideDetailWidthPx: () => {},
});
export function SectionWidthProvider({ children }: { children: ReactNode }) {
const [maxWidth, setMaxWidth] = useState("");
+ const [sideDetailWidthPx, setSideDetailWidthPx] = useState(0);
+ const value = useMemo(
+ () => ({ maxWidth, setMaxWidth, sideDetailWidthPx, setSideDetailWidthPx }),
+ [maxWidth, sideDetailWidthPx],
+ );
return (
-
+
{children}
);
@@ -34,6 +45,16 @@ export function useSectionWidth() {
return useContext(SectionWidthContext);
}
+/** 상세 패널이 열려 있는 동안 그 폭을 레이아웃 최소 폭에 더한다. 닫히거나 화면을 떠나면 0으로 돌린다. */
+export function SetSectionSideDetailWidth({ px }: { px: number }) {
+ const { setSideDetailWidthPx } = useSectionWidth();
+ useEffect(() => {
+ setSideDetailWidthPx(px);
+ return () => setSideDetailWidthPx(0);
+ }, [px, setSideDetailWidthPx]);
+ return null;
+}
+
/** Drop into any page to declaratively set the section max-width. */
export function SetSectionMaxWidth({ value }: { value: WidthToken }) {
const { setMaxWidth } = useSectionWidth();
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/analytics/room-context.test.ts b/src/lib/analytics/room-context.test.ts
index e34325a5..8115c1ef 100644
--- a/src/lib/analytics/room-context.test.ts
+++ b/src/lib/analytics/room-context.test.ts
@@ -11,7 +11,7 @@ describe("room route boundaries", () => {
);
});
describe("optional analytics room context", () => {
- it.each(["/plan", "/plan/room-a", "/map", "/map?view=route", "/chat", "/search", "/cost", "/bookmark/folder-b", "/settings", "/member-settings", "/room-settings"])(
+ it.each(["/plan", "/plan/room-a", "/map", "/map?view=route", "/chat", "/search", "/cost", "/bookmark/folder-b", "/settings", "/member-settings"])(
"uses confirmed selected room for %s", (pathname) => {
expect(resolveAnalyticsRoomId({ ...session, pathname })).toBe("room-a");
},
diff --git a/src/lib/analytics/room-context.ts b/src/lib/analytics/room-context.ts
index b4f0ee6e..7d12ad3f 100644
--- a/src/lib/analytics/room-context.ts
+++ b/src/lib/analytics/room-context.ts
@@ -1,7 +1,7 @@
import { parseRoomContextPath } from "@/lib/room-context-path";
const roomRootPaths = new Set([
- "plan", "map", "chat", "bookmark", "search", "cost", "settings", "room-settings", "member-settings",
+ "plan", "map", "chat", "bookmark", "search", "cost", "settings", "member-settings",
]);
const roomDetailPaths = new Set(["plan", "packing", "bookmark"]);
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/auth-session.test.ts b/src/lib/auth-session.test.ts
index 3c6b9e96..f00e4ce5 100644
--- a/src/lib/auth-session.test.ts
+++ b/src/lib/auth-session.test.ts
@@ -16,7 +16,6 @@ describe("isProtectedAppPath", () => {
"/bookmark/folder-1",
"/search",
"/member-settings",
- "/room-settings",
"/settings",
"/waiting",
])("treats %s as a protected app URL", (pathname) => {
diff --git a/src/lib/auth-session.ts b/src/lib/auth-session.ts
index 666a6d9e..c6cb9d86 100644
--- a/src/lib/auth-session.ts
+++ b/src/lib/auth-session.ts
@@ -52,7 +52,6 @@ export function isProtectedAppPath(pathname: string): boolean {
"/bookmark",
"/search",
"/member-settings",
- "/room-settings",
"/settings",
"/waiting",
];
diff --git a/src/lib/layout-tokens.ts b/src/lib/layout-tokens.ts
index 1253a15b..d2cc50c7 100644
--- a/src/lib/layout-tokens.ts
+++ b/src/lib/layout-tokens.ts
@@ -6,9 +6,18 @@ export const width = {
/** Figma 사이드바 1049:2992 · HeaderBar 홈 열 공통 폭 */
export const MAIN_SIDEBAR_RAIL_WIDTH = "68px" as const;
-/** 채팅·LeftSection 최소 폭 */
+/** 채팅·일정 등 좌측 패널 최소 폭(사이드바 레일 제외) */
export const CHAT_PANEL_DOCKED_WIDTH = width.s1;
+/**
+ * 데스크톱 지도 최소 폭 — 장소 상세 패널(360) + 좌우 여백 10.
+ * 좌측 영역(레일 + 패널 400)과 합친 폭보다 화면이 좁으면 레이아웃 안에서 가로 스크롤한다.
+ */
+export const MAIN_MAP_MIN_WIDTH_PX = 380 as const;
+
+/** 데스크톱 준비물 상세 패널 폭 — `PackingPage.module.css`의 `.detail`과 같게 유지 */
+export const PACKING_DETAIL_WIDTH_PX = 440 as const;
+
/**
* 플랜 루트 콘텐츠 박스 인라인 폭이 이 값 미만이면 narrow.
* `width.s1`(채팅·좌측 최소 400px) 과 맞춤.
@@ -30,29 +39,31 @@ export const PLAN_PLACE_CARD_MIN_SIZE_REM = "6rem" as const;
*/
export const PLAN_PLACE_CARD_TW = {
article:
- "relative grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-3.5 rounded-xl border border-gray-border bg-white p-3.5 shadow-sm mobile:gap-x-2.5 mobile:p-2.5",
+ "relative grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-3 rounded-xl border border-gray-border bg-white p-3.5 shadow-sm mobile:gap-x-2.5 mobile:p-2.5",
thumbnail:
"relative h-[6rem] w-[6rem] shrink-0 overflow-hidden rounded-lg bg-fill-strong mobile:h-[4.5rem] mobile:w-[4.5rem]",
+ /** 장소명·카테고리는 위, 주소는 썸네일 아래 끝에 맞춘다 */
contentColumn:
- "flex min-h-[6rem] min-w-0 flex-col justify-start gap-2 mobile:min-h-[4.5rem]",
+ "flex min-h-[6rem] min-w-0 flex-col justify-between gap-2 pb-1 mobile:min-h-[4.5rem]",
titleRow: "flex min-w-0 items-start gap-2.5",
- subtitle: "text-body-xs-regular leading-snug text-dark-gray/85",
+ subtitle: "truncate text-caption-l-regular text-text-subtle",
controlsStack: "flex min-w-0 flex-col gap-0.5",
orderBadgeCompact:
"absolute -left-2 -top-2 z-10 h-8 w-8 rounded-lg border-2 border-white text-body-s-regular mobile:text-body-xs-regular shadow-md",
titleCompact: "text-body-m-emphasis mobile:text-body-s-emphasis font-semibold leading-snug text-gray-900",
- titleClamp: "line-clamp-2",
- subtitleClamp: "line-clamp-2",
+ title: "truncate text-body-l-emphasis mobile:text-body-s-emphasis text-text",
primaryTypeBadge:
- "shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-body-xs-emphasis font-medium leading-none text-dark-gray",
+ "w-fit max-w-full shrink-0 truncate rounded-full bg-fill px-1.5 py-0.5 text-body-xs-regular text-text-subtle",
deleteButtonCompact: "p-1 @min-[370px]/plan:p-1.5",
- deleteIconCompact: "h-5 w-5",
- triggerRow:
- "flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1",
- triggerButton:
- "flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-label-m-regular mobile:text-label-s-regular font-medium text-dark-gray/85 transition hover:bg-sky-50 hover:text-sky-600",
- triggerButtonActive: "text-sky-600 font-semibold",
- triggerIcon: "h-4 w-4 shrink-0",
+ deleteIconSize: 16,
+ /** 카드 아래 시간·비용(왼쪽)과 메모(오른쪽) 버튼 줄 — 버튼 배경이 카드 안쪽 여백보다 바깥에 오도록 좌우를 넓힌다 */
+ actionBar: "-mx-1.5 flex min-w-0 items-center justify-between gap-2",
+ /** 디자인 시스템 Button Base(sm) — 버튼 좌우 여백 10, 아이콘 16, 라벨(`span`) 좌우 여백 6 */
+ actionButton:
+ "flex h-9 shrink-0 cursor-pointer items-center rounded-md bg-fill-subtle px-2.5 text-body-s-emphasis text-text transition-colors hover:bg-fill disabled:cursor-not-allowed disabled:opacity-50 [&>span]:px-1.5",
+ /** 메모 버튼만 Button Base 좌우 여백이 4다(디자인) */
+ actionButtonTrailing: "px-1",
+ actionIcon: "shrink-0 text-primary",
editorModule:
"relative flex w-full items-start gap-3 rounded-2xl bg-gray-100/70 px-4 py-3 text-body-s-regular mobile:text-body-xs-regular text-gray-900",
editorSideLabelWrapper:
diff --git a/src/lib/layout/mainChromeLayoutWidth.test.ts b/src/lib/layout/mainChromeLayoutWidth.test.ts
index bf973823..3b0a735c 100644
--- a/src/lib/layout/mainChromeLayoutWidth.test.ts
+++ b/src/lib/layout/mainChromeLayoutWidth.test.ts
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import { resolveLeftSectionTargetMaxWidthPx, resolveDesktopLeftSectionMinWidthPx } from "./mainChromeLayoutWidth";
it("keeps the schedule width across collaboration tabs and /chat, including page width effects", () => {
- for (const pathname of ["/plan", "/plan/room", "/chat", "/search", "/bookmark", "/bookmark/folder", "/member-settings", "/room-settings", "/settings"]) {
+ for (const pathname of ["/plan", "/plan/room", "/chat", "/search", "/bookmark", "/bookmark/folder", "/member-settings", "/settings"]) {
for (const contentWidthToken of ["", "400px"]) {
expect(resolveLeftSectionTargetMaxWidthPx({ pathname, contentWidthToken, isMobile: false })).toBe(720);
expect(resolveLeftSectionTargetMaxWidthPx({ pathname, contentWidthToken, isMobile: true })).toBeNull();
@@ -15,8 +15,10 @@ it("lets the ledger and packing use the full desktop workspace", () => {
expect(resolveLeftSectionTargetMaxWidthPx({ pathname: "/packing/room", contentWidthToken: "400px", isMobile: false })).toBeNull();
});
-it("removes the desktop minimum width only for packing", () => {
- expect(resolveDesktopLeftSectionMinWidthPx("/packing/room")).toBe(0);
- expect(resolveDesktopLeftSectionMinWidthPx("/plan/room")).toBe(400);
- expect(resolveDesktopLeftSectionMinWidthPx("/chat")).toBe(400);
+it("keeps the 400px panel beside the rail and holds map-less tools at the full desktop minimum", () => {
+ expect(resolveDesktopLeftSectionMinWidthPx("/plan/room")).toBe(469);
+ expect(resolveDesktopLeftSectionMinWidthPx("/chat")).toBe(469);
+ expect(resolveDesktopLeftSectionMinWidthPx("/cost")).toBe(849);
+ expect(resolveDesktopLeftSectionMinWidthPx("/packing/room")).toBe(849);
+ expect(resolveDesktopLeftSectionMinWidthPx("/packing/room", 440)).toBe(909);
});
diff --git a/src/lib/layout/mainChromeLayoutWidth.ts b/src/lib/layout/mainChromeLayoutWidth.ts
index a8cfc1eb..945349b2 100644
--- a/src/lib/layout/mainChromeLayoutWidth.ts
+++ b/src/lib/layout/mainChromeLayoutWidth.ts
@@ -1,6 +1,7 @@
import { isPackingPath } from "@/lib/room-context-path";
import {
CHAT_PANEL_DOCKED_WIDTH,
+ MAIN_MAP_MIN_WIDTH_PX,
MAIN_SIDEBAR_RAIL_WIDTH,
width,
} from "@/lib/layout-tokens";
@@ -57,9 +58,7 @@ export function resolveRouteDefaultContentWidth(pathname: string): string {
pathname === "/bookmark" ||
pathname.startsWith("/bookmark/") ||
pathname === "/member-settings" ||
- pathname.startsWith("/member-settings/") ||
- pathname === "/room-settings" ||
- pathname.startsWith("/room-settings/")
+ pathname.startsWith("/member-settings/")
) {
return width.s1;
}
@@ -108,6 +107,24 @@ export function resolveLeftSectionAnimateMaxWidth({
return "none";
}
-export function resolveDesktopLeftSectionMinWidthPx(pathname = ""): number {
- return isPackingPath(pathname) ? 0 : parseLayoutLengthToPx(CHAT_PANEL_DOCKED_WIDTH);
+/** 데스크톱 `LeftSection` 오른쪽 테두리 — 최소 폭(border-box)에 더해야 패널이 400을 다 쓴다 */
+const LEFT_SECTION_BORDER_PX = 1;
+
+/**
+ * 데스크톱 좌측 영역 최소 폭.
+ * - 일정·채팅 등: 패널 400 + 사이드바 레일 + 테두리(469). 옆 지도 최소 380과 합쳐 레이아웃 최소 849.
+ * - 지도 없이 전체 폭을 쓰는 가계부·준비물: 좌측 영역이 레이아웃 최소 849를 다 맡는다.
+ * - 패널 오른쪽에 상세가 붙으면(준비물 상세 440) 패널 400을 지킨 채 상세 폭을 더한다.
+ * 화면이 이보다 좁으면 레이아웃 안에서 가로 스크롤한다.
+ */
+export function resolveDesktopLeftSectionMinWidthPx(pathname = "", sideDetailWidthPx = 0): number {
+ const panelMinPx =
+ parseLayoutLengthToPx(CHAT_PANEL_DOCKED_WIDTH) +
+ parseLayoutLengthToPx(MAIN_SIDEBAR_RAIL_WIDTH) +
+ LEFT_SECTION_BORDER_PX;
+ const withDetailPx = panelMinPx + Math.max(0, sideDetailWidthPx);
+ if (pathname === "/cost" || isPackingPath(pathname)) {
+ return Math.max(panelMinPx + MAIN_MAP_MIN_WIDTH_PX, withDetailPx);
+ }
+ return withDetailPx;
}
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/maps.ts b/src/lib/maps.ts
index 54e31569..5c70a3de 100644
--- a/src/lib/maps.ts
+++ b/src/lib/maps.ts
@@ -148,6 +148,23 @@ export const MAP_PAN_RESTRICTION: google.maps.MapRestriction = {
strictBounds: true,
};
+/**
+ * 가로 래핑을 막은 지도({@link MAP_PAN_RESTRICTION})에서 `target`으로 옮긴다.
+ * `panTo`는 날짜변경선(경도 ±180)을 넘는 쪽이 더 가까우면 그쪽으로 가다 경계에 막혀 바다 한가운데에 멈추므로
+ * (예: 샌프란시스코 → 도쿄), 그때만 `setCenter`로 바로 옮긴다. 이만큼 먼 이동은 `panTo`도 애니메이션 없이 건너뛴다.
+ */
+export function panToWithinWorld(
+ map: google.maps.Map,
+ target: google.maps.LatLngLiteral,
+): void {
+ const center = map.getCenter();
+ if (center && Math.abs(target.lng - center.lng()) > 180) {
+ map.setCenter(target);
+ return;
+ }
+ map.panTo(target);
+}
+
/** Google Places 리소스 id(`places/…`) 접두를 제거한 레거시 id */
export function normalizeGooglePlaceResourceId(id: string): string {
const prefix = "places/";
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/lib/plan/mobilePlanFormat.test.ts b/src/lib/plan/mobilePlanFormat.test.ts
index 1ff51485..aacf3d84 100644
--- a/src/lib/plan/mobilePlanFormat.test.ts
+++ b/src/lib/plan/mobilePlanFormat.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { Expense } from "@/lib/api/rooms/expenses";
-import { formatMobileDayDate, formatMonthDayKo, summarizeExpensesForMobile } from "./mobilePlanFormat";
+import { formatMobileDayDate, formatMonthDayKo, summarizePlaceExpenses } from "./mobilePlanFormat";
function expense(currency: string, totalAmount: string, id: number): Expense {
return { id, currency, totalAmount } as Expense;
@@ -31,30 +31,21 @@ describe("formatMobileDayDate", () => {
});
});
-describe("summarizeExpensesForMobile", () => {
- it("지출이 없으면 null", () => {
- expect(summarizeExpensesForMobile([])).toBeNull();
- });
-
- it("통화가 하나면 합계만 보여준다", () => {
- expect(
- summarizeExpensesForMobile([expense("KRW", "45000", 1), expense("KRW", "5000", 2)]),
- ).toBe("50,000 KRW");
- });
-
- it("지출이 가장 많은 통화 합계에 다른 통화 지출 건수를 붙인다", () => {
- expect(
- summarizeExpensesForMobile([
- expense("AFN", "123.00", 1),
- expense("KRW", "300", 2),
- expense("KRW", "57", 3),
- ]),
- ).toBe("357 KRW 외 1건");
- });
-
- it("건수가 같으면 먼저 나온 통화를 쓴다", () => {
- expect(
- summarizeExpensesForMobile([expense("JPY", "3600", 1), expense("KRW", "1000", 2)]),
- ).toBe("3,600 JPY 외 1건");
+describe("summarizePlaceExpenses", () => {
+ it.each([
+ { name: "empty", rows: [], want: null },
+ { name: "single KRW", rows: [["KRW", "1000"]], want: "1,000 KRW" },
+ { name: "single foreign", rows: [["JPY", "3600"]], want: "3,600 JPY" },
+ { name: "same currency", rows: [["USD", "10.10"], ["USD", "20.20"]], want: "30.30 USD" },
+ { name: "most entries, not largest amount", rows: [["KRW", "1000000"], ["USD", "10"], ["USD", "20"]], want: "30 USD 외 1건" },
+ { name: "tie uses first occurrence; suffix counts entries not currencies", rows: [["USD", "10"], ["EUR", "20"], ["EUR", "30"], ["USD", "5"]], want: "15 USD 외 2건" },
+ { name: "zero is an expense", rows: [["JPY", "0"]], want: "0 JPY" },
+ { name: "zero entries participate in counts", rows: [["USD", "0"], ["USD", "0"], ["KRW", "1000"]], want: "0 USD 외 1건" },
+ { name: "large precise amount", rows: [["USD", "12345678901234567890.12"], ["USD", "0.01"]], want: "12,345,678,901,234,567,890.13 USD" },
+ ])("$name", ({ rows, want }) => {
+ const records = rows.map(([currency, amount], index) => expense(currency, amount, index));
+ const before = structuredClone(records);
+ expect(summarizePlaceExpenses(records)).toBe(want);
+ expect(records).toEqual(before);
});
});
diff --git a/src/lib/plan/mobilePlanFormat.ts b/src/lib/plan/mobilePlanFormat.ts
index 58a994b5..1dbd1627 100644
--- a/src/lib/plan/mobilePlanFormat.ts
+++ b/src/lib/plan/mobilePlanFormat.ts
@@ -22,10 +22,11 @@ export function formatMonthDayKo(ymd: string): string {
}
/**
- * 모바일 비용 요약 — 지출이 가장 많은 통화의 합계만 보여주고, 다른 통화 지출은 건수로 붙인다.
- * 예: `357 KRW`, `357 KRW 외 1건`. 지출이 없으면 null.
+ * 일차·장소 비용 요약 — 가장 많은 지출 건수의 통화를 원금액으로 합산한다.
+ * 동률이면 처음 등장한 통화, `외 N건`은 나머지 통화의 지출 건수다.
+ * 예: `30 USD 외 1건`. 0원도 지출이며, 빈 목록만 null이다.
*/
-export function summarizeExpensesForMobile(expenses: readonly Expense[]): string | null {
+export function summarizePlaceExpenses(expenses: readonly Expense[]): string | null {
if (expenses.length === 0) return null;
const countByCurrency = new Map();
for (const expense of expenses) {
diff --git a/src/lib/plan/scheduleMerge.ts b/src/lib/plan/scheduleMerge.ts
index 35f3a9b0..66de0599 100644
--- a/src/lib/plan/scheduleMerge.ts
+++ b/src/lib/plan/scheduleMerge.ts
@@ -3,6 +3,7 @@ import type { PlanDayData } from "@/lib/plan/types";
import {
formatKoreanDateLabel,
+ formatShortDateLabel,
parseLocalYmd,
} from "@/lib/plan/tripRange";
@@ -23,6 +24,7 @@ export function mergeSchedulesWithPlaces(schedules: RoomSchedule[]): PlanDayData
id: `schedule-${s.scheduleId}`,
dayLabel: `${i + 1}일차`,
dateLabel: formatKoreanDateLabel(parseLocalYmd(s.date)),
+ shortDateLabel: formatShortDateLabel(parseLocalYmd(s.date)),
places: [],
}));
}
diff --git a/src/lib/plan/tripRange.ts b/src/lib/plan/tripRange.ts
index acd2c46e..764d58a8 100644
--- a/src/lib/plan/tripRange.ts
+++ b/src/lib/plan/tripRange.ts
@@ -35,6 +35,13 @@ export function formatKoreanDateLabel(d: Date): string {
return `${d.getMonth() + 1}월 ${d.getDate()}일 (${w})`;
}
+/** 일정 일차 헤더용 짧은 날짜 — `10.12(월)` */
+export function formatShortDateLabel(d: Date): string {
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${m}.${day}(${WEEKDAYS_KO[d.getDay()]})`;
+}
+
function formatYmdKoShort(ymd: string): string {
const d = parseLocalYmd(ymd);
return `${d.getMonth() + 1}월 ${d.getDate()}일`;
diff --git a/src/lib/plan/types.ts b/src/lib/plan/types.ts
index f5573e59..7ea2c1ac 100644
--- a/src/lib/plan/types.ts
+++ b/src/lib/plan/types.ts
@@ -26,6 +26,9 @@ export type PlanPlace = {
export type PlanDayData = {
id: string;
dayLabel: string;
+ /** `10월 12일 (월)` — 가계부 패널 부제목 등 */
dateLabel: string;
+ /** `10.12(월)` — 일차 헤더 */
+ shortDateLabel: string;
places: PlanPlace[];
};
diff --git a/src/proxy.test.ts b/src/proxy.test.ts
index e68b793c..d2f7b5c0 100644
--- a/src/proxy.test.ts
+++ b/src/proxy.test.ts
@@ -97,7 +97,6 @@ describe("protected route redirect indexing contract", () => {
"/cost",
"/cost/detail",
"/member-settings",
- "/room-settings",
"/settings",
"/waiting",
])("marks the unauthenticated %s redirect noindex, nofollow", async (path) => {
diff --git a/src/proxy.ts b/src/proxy.ts
index 06928251..49b9f859 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -121,8 +121,6 @@ export const config = {
"/search/:path*",
"/member-settings",
"/member-settings/:path*",
- "/room-settings",
- "/room-settings/:path*",
"/settings",
"/settings/:path*",
"/waiting",
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 }),
+}));
diff --git a/src/stores/plan-map-directions-epoch-store.ts b/src/stores/plan-map-directions-epoch-store.ts
index b96a69f3..f38add8f 100644
--- a/src/stores/plan-map-directions-epoch-store.ts
+++ b/src/stores/plan-map-directions-epoch-store.ts
@@ -5,18 +5,6 @@ type PlanMapSegmentEpochEntry = {
segmentSourceItemId: number;
};
-export type PlanMapRouteRenderStatus =
- | "loading"
- | "available"
- /** 경로는 있는데 폴리라인을 그릴 수 없음 — 렌더링 경고 대상 */
- | "unavailable"
- /** 구간에 경로 자체가 없음 — 요약이 이미 "이동 안내 없음"을 알리므로 경고하지 않음 */
- | "no-route";
-
-type PlanMapRouteRenderStatusEntry = PlanMapSegmentEpochEntry & {
- status: PlanMapRouteRenderStatus;
-};
-
/** `epochBySegmentKey`: `{roomId}:{scheduleId}:{segmentSourceItemId}` */
export function planMapSegmentEpochStoreKey(
roomId: string,
@@ -30,19 +18,13 @@ export function planMapSegmentEpochStoreKey(
type EpochState = {
epochByRoomId: Record;
epochBySegmentKey: Record;
- routeRenderStatusBySegmentKey: Record;
bumpForDirections: (roomId: string) => void;
bumpSegments: (roomId: string, entries: PlanMapSegmentEpochEntry[]) => void;
- syncRouteRenderStatuses: (
- roomId: string,
- entries: PlanMapRouteRenderStatusEntry[],
- ) => void;
};
export const usePlanMapDirectionsEpochStore = create((set) => ({
epochByRoomId: {},
epochBySegmentKey: {},
- routeRenderStatusBySegmentKey: {},
bumpForDirections: (roomId) => {
const rid = String(roomId ?? "").trim();
if (!rid.length) return;
@@ -65,34 +47,4 @@ export const usePlanMapDirectionsEpochStore = create((set) => ({
return { epochBySegmentKey: next };
});
},
- syncRouteRenderStatuses: (roomId, entries) => {
- const rid = String(roomId ?? "").trim();
- if (!rid.length) return;
- const roomKeyPrefix = `${rid}:`;
- set((s) => {
- const next: Record = {};
- for (const [key, status] of Object.entries(
- s.routeRenderStatusBySegmentKey,
- )) {
- if (!key.startsWith(roomKeyPrefix)) next[key] = status;
- }
- for (const entry of entries) {
- next[
- planMapSegmentEpochStoreKey(
- rid,
- entry.scheduleId,
- entry.segmentSourceItemId,
- )
- ] = entry.status;
- }
-
- const previous = s.routeRenderStatusBySegmentKey;
- const previousKeys = Object.keys(previous);
- const nextKeys = Object.keys(next);
- const unchanged =
- previousKeys.length === nextKeys.length &&
- nextKeys.every((key) => previous[key] === next[key]);
- return unchanged ? s : { routeRenderStatusBySegmentKey: next };
- });
- },
}));