Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/analytics/amplitude-tracking-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
| `packing_item_checked` | 본인 체크 요청 응답으로 미완료 → 완료가 확정됐을 때 | `room_id` (Amplitude만) | 없음 | Amplitude |
| `packing_item_unchecked` | 본인 체크 요청 응답으로 완료 → 미완료가 확정됐을 때 | `room_id` (Amplitude만) | 없음 | Amplitude |

웹 전체 검색은 결과 핀만 표시하며, 핀의 Text Search 미리보기 말풍선은 `view_place`로 세지 않는다. 말풍선의 화살표를 눌러 기존 상세 패널의 데이터가 표시될 때 `view_place`를 기록하고 원래 검색 결과 순서의 `rank_bucket`을 유지한다. 모바일 결과 카드·상세 흐름과 자동완성 특정 장소 선택은 기존 기준을 유지한다.

준비물 체크는 상태가 실제로 바뀐 경우만 기록한다. 자동 목록 초기화, 읽기, 낙관적 변경, 실패·불확실한 응답, 중복·차단 요청은 포함하지 않는다. 가계부/준비물 쓰기는 성공 응답 뒤 후속 조회가 실패해도 집계하되, 계정·방·권한이 바뀐 뒤의 응답을 다른 사용자에게 귀속하지 않는다. 브로드캐스트 수신자의 이벤트는 만들지 않는다.

정산 요약 조회는 버튼 클릭이나 로딩·오류가 아니라 성공 데이터 표시를 뜻한다. 열린 동안 재렌더·재조회로 중복하지 않으며 닫았다 다시 열면 새 조회다. 송금·정산 완료 이벤트는 구현하지 않는다. `chat_message_sent`도 서버 저장·AI 응답 성공으로 해석하지 않는다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import { BookmarkFolderDetailHeader } from "./BookmarkFolderDetailHeader";
import { BookmarkListSkeleton } from "./BookmarkListSkeleton";
import { BookmarkPlaceRow } from "./BookmarkPlaceRow";

// 장소 추가·삭제 시 데스크톱 목록 행의 너비가 흔들리지 않게 한다.
const SCROLLBAR =
"min-h-0 flex-1 overflow-y-auto [scrollbar-color:rgba(0,0,0,0.2)_transparent]";
"min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable] mobile:[scrollbar-gutter:auto] [scrollbar-color:rgba(0,0,0,0.2)_transparent]";

function trimGooglePlaceId(raw: unknown): string {
return typeof raw === "string" ? raw.trim() : "";
Expand Down
2 changes: 1 addition & 1 deletion src/app/(main)/map/_components/MobileRoutePlaceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export function MobileRouteExpandedShell({
>
<span aria-hidden className="h-1 w-9 rounded-[2px] bg-border-subtle" />
</button>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">{children}</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain">{children}</div>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import { MobileRoutePlaceDetail } from "./MobileRoutePlaceDetail";
const boundary = vi.hoisted(() => ({
tabs: vi.fn(),
retry: vi.fn(),
track: vi.fn(),
}));
vi.mock("@/hooks/useAnalyticsRoomId", () => ({
useAnalyticsRoomId: () => "room",
}));
vi.mock("@/components/place/usePlaceDetailData", () => ({
usePlaceDetailData: () => ({
isLoading: false,
isError: true,
refetch: boundary.retry,
}),
}));
vi.mock("@/components/place/HeroSection", () => ({
HeroImage: () => null,
HeroSkeleton: () => null,
}));
vi.mock("@/components/place/PlaceDetailTabs", () => ({
PlaceDetailTabs: (props: unknown) => {
boundary.tabs(props);
return <p>리뷰 없음</p>;
},
}));
vi.mock("@/lib/analytics/track", () => ({
AnalyticsEvents: { viewPlace: "view_place" },
trackAnalyticsEvent: boundary.track,
}));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
it("failed full Details is retryable and never represented as no reviews or a successful view", async () => {
const host = document.createElement("div");
const root = createRoot(host);
await act(async () =>
root.render(
<MobileRoutePlaceDetail
place={{ googlePlaceId: "one", title: "카페" }}
active
analyticsSource="search"
/>,
),
);
expect(boundary.tabs).not.toHaveBeenCalled();
expect(boundary.track).not.toHaveBeenCalled();
expect(host.querySelector('[role="alert"]')).not.toBeNull();
await act(async () => host.querySelector("button")!.click());
expect(boundary.retry).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
75 changes: 45 additions & 30 deletions src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PlaceDetailTabs } from "@/components/place/PlaceDetailTabs";
import { usePlaceDetailData } from "@/components/place/usePlaceDetailData";
import { AnalyticsEvents, trackAnalyticsEvent, type AnalyticsSource } from "@/lib/analytics/track";
import { normalizeGooglePlaceResourceId } from "@/lib/maps";
import type { SearchRankBucket } from "@/lib/analytics/context";
import type { PlanPlace } from "@/lib/plan/types";

/**
Expand All @@ -19,31 +20,71 @@ export function MobileRoutePlaceDetail({
place,
active,
analyticsSource = "plan",
analyticsRankBucket,
}: Readonly<{
place: Pick<PlanPlace, "googlePlaceId" | "title" | "subtitle" | "location" | "imageUrl" | "primaryTypeDisplayName">;
active: boolean;
analyticsSource?: AnalyticsSource;
analyticsRankBucket?: SearchRankBucket;
}>) {
const analyticsRoomId = useAnalyticsRoomId();
const rawId = place.googlePlaceId?.trim() ?? "";
const googlePlaceId = rawId ? normalizeGooglePlaceResourceId(rawId) : undefined;
const { data: detail, isLoading } = usePlaceDetailData(active ? googlePlaceId : undefined);
const { data: detail, isLoading, isError, refetch } = usePlaceDetailData(active ? googlePlaceId : undefined);
const category = detail?.primaryTypeDisplayName ?? place.primaryTypeDisplayName ?? "";

// 일정 상세를 본 것으로 한 번만 기록한다(장소 상세 패널과 같은 이벤트)
const trackedPlaceIdRef = useRef<string | null>(null);
useEffect(() => {
if (!active || !googlePlaceId || isLoading || trackedPlaceIdRef.current === googlePlaceId) return;
if (!active || !googlePlaceId || isLoading || isError || trackedPlaceIdRef.current === googlePlaceId) return;
trackedPlaceIdRef.current = googlePlaceId;
trackAnalyticsEvent(AnalyticsEvents.viewPlace, {
place_category: category,
interaction_source: analyticsSource,
...(analyticsRankBucket ? { rank_bucket: analyticsRankBucket } : {}),
...(analyticsRoomId ? { room_id: analyticsRoomId } : {}),
});
}, [active, analyticsSource, analyticsRoomId, category, googlePlaceId, isLoading]);
}, [active, analyticsSource, analyticsRankBucket, analyticsRoomId, category, googlePlaceId, isLoading, isError]);

const showSkeleton = !active || (isLoading && !detail);

function renderDetail() {
if (isError && !detail && active) return (
<div role="alert" className="m-3.5 rounded-md border border-border-subtle p-3 text-body-s-regular text-text">
<p>장소 정보를 불러오지 못했어요.</p>
<button type="button" onClick={() => void refetch()} className="mt-2 text-primary underline">다시 시도</button>
</div>
);
if (showSkeleton) return (
<output aria-label="장소 정보 불러오는 중" aria-busy className="mt-3 flex flex-col gap-2 px-5 py-4">
<span className="h-5 w-24 animate-pulse rounded bg-fill-strong" />
<span className="h-16 w-full animate-pulse rounded-md bg-fill" />
</output>
);
return (
// 장소 상세 시트와 같은 홈·리뷰 탭 — 같은 상세 응답을 쓰므로 추가 호출이 없다
<div className="mt-3">
<PlaceDetailTabs
home={{
isOpen: detail?.openNow,
address: place.subtitle ?? detail?.formattedAddress,
phone: detail?.phone,
hours: detail?.weekdayDescriptions?.join("\n"),
website: detail?.websiteUri,
googleMapsUrl: detail?.placeUri ?? undefined,
reviewSummary: detail?.reviewSummary,
}}
reviews={{
rating: detail?.rating ?? null,
userRatingCount: detail?.userRatingCount,
reviews: detail?.reviews ?? [],
reviewsUri: detail?.reviewsUri,
}}
/>
</div>
);
}

return (
<div className="flex flex-col">
<div className="mx-3.5 h-[130px] overflow-hidden rounded-md bg-fill">
Expand All @@ -54,33 +95,7 @@ export function MobileRoutePlaceDetail({
)}
</div>

{showSkeleton ? (
<output aria-label="장소 정보 불러오는 중" aria-busy className="mt-3 flex flex-col gap-2 px-5 py-4">
<span className="h-5 w-24 animate-pulse rounded bg-fill-strong" />
<span className="h-16 w-full animate-pulse rounded-md bg-fill" />
</output>
) : (
// 장소 상세 시트와 같은 홈·리뷰 탭 — 같은 상세 응답을 쓰므로 추가 호출이 없다
<div className="mt-3">
<PlaceDetailTabs
home={{
isOpen: detail?.openNow,
address: place.subtitle ?? detail?.formattedAddress,
phone: detail?.phone,
hours: detail?.weekdayDescriptions?.join("\n"),
website: detail?.websiteUri,
googleMapsUrl: detail?.placeUri ?? undefined,
reviewSummary: detail?.reviewSummary,
}}
reviews={{
rating: detail?.rating ?? null,
userRatingCount: detail?.userRatingCount,
reviews: detail?.reviews ?? [],
reviewsUri: detail?.reviewsUri,
}}
/>
</div>
)}
{renderDetail()}

</div>
);
Expand Down
144 changes: 19 additions & 125 deletions src/app/(main)/search/page.lifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -1,139 +1,33 @@
// @vitest-environment jsdom
import { act, type ComponentProps } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import SearchPage from "./page";
import { useMapCenterStore } from "@/stores/map-center-store";
import { useSearchRecenterStore } from "@/stores/search-recenter-store";
import type { PlacesSearchInput } from "@/components/search/PlacesSearchInput";
const state = vi.hoisted(() => ({
roomId: "room-a" as string | undefined,
fetching: false,
params: new URLSearchParams("q=cafe"),
mobile: false,
params: new URLSearchParams("q=원래 검색어"),
replace: vi.fn(),
track: vi.fn(),
input: null as ComponentProps<typeof PlacesSearchInput> | null,
args: [] as unknown[],
}));
vi.mock("@/hooks/useAnalyticsRoomId", () => ({ useAnalyticsRoomId: () => state.roomId }));
vi.mock("next/navigation", () => ({
useSearchParams: () => state.params,
useRouter: () => ({ replace: state.replace }),
}));
vi.mock("@/hooks/usePlacesSearch", () => ({
usePlacesSearch: (...args: unknown[]) => {
state.args = args;
return { items: [], pageIndex: 0, isSuccess: true, isFetching: state.fetching };
},
}));
vi.mock("@/components/search/PlacesSearchInput", () => ({
PlacesSearchInput: (props: ComponentProps<typeof PlacesSearchInput>) => {
state.input = props;
return null;
},
}));
vi.mock("@/components/place", () => ({ SearchResultCard: () => null }));
// 카드 클릭(장소 열기)은 이 테스트 범위 밖 — 지도 이동 훅의 라우터·세션 의존성을 끊는다
vi.mock("@/hooks/useOpenPlaceOnMap", () => ({ useOpenPlaceOnMap: () => () => {} }));
vi.mock("@/components/layout/MainPageHeader", () => ({
MainPageHeader: () => null,
}));
vi.mock("@/contexts/SectionWidthContext", () => ({
SetSectionMaxWidth: () => null,
}));
vi.mock("@/lib/analytics/track", () => ({
AnalyticsEvents: { search: "search" },
trackAnalyticsEvent: state.track,
vi.mock("@/contexts/MobileViewContext", () => ({
useMobileView: () => ({ isMobileDevice: state.mobile }),
}));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
it("commits URL, same-text and recenter searches once; camera drags alone do not search", async () => {
useMapCenterStore.setState({
mapCenter: null,
zoom: null,
radiusMeters: null,
});
const root = createRoot(document.createElement("div"));
const render = async () => {
it.each([false, true])(
"keeps legacy query links by handing them to the common map search (mobile=%s)",
async (mobile) => {
state.mobile = mobile;
state.replace.mockReset();
const host = document.createElement("div");
const root = createRoot(host);
await act(async () => root.render(<SearchPage />));
};
await render();
expect(state.args[1]).toBeNull();
await act(async () =>
useMapCenterStore
.getState()
.setMapCamera({
mapCenter: { lat: 1, lng: 2 },
zoom: 12,
radiusMeters: 1000,
}),
);
expect(state.args.slice(0, 4)).toEqual(["cafe", 1, 2, 1000]);
expect(state.track).toHaveBeenCalledTimes(1);
await act(async () =>
useMapCenterStore
.getState()
.setMapCamera({
mapCenter: { lat: 3, lng: 4 },
zoom: 13,
radiusMeters: 2000,
}),
);
expect(state.args[1]).toBe(1);
expect(state.track).toHaveBeenCalledTimes(1);
await act(async () => state.input!.onSearch("food"));
state.params = new URLSearchParams("q=food");
await render();
expect(state.track).toHaveBeenCalledTimes(2);
expect(state.args.slice(0, 4)).toEqual(["food", 3, 4, 2000]);
await act(async () => state.input!.onSearch("food"));
expect(state.track).toHaveBeenCalledTimes(3);
await act(async () =>
useSearchRecenterStore.getState().requestSearchRecenter(),
);
expect(state.track).toHaveBeenCalledTimes(4);
expect(state.track.mock.lastCall?.[1].search_mode).toBe("map_recenter");
state.params = new URLSearchParams("q=park");
await render();
expect(state.track).toHaveBeenCalledTimes(5);
expect(state.track.mock.lastCall?.[1].search_mode).toBe("text");
await act(async () => root.unmount());
});

it("keeps the existing default radius when the camera radius is non-finite", async () => {
state.params = new URLSearchParams("q=cafe");
useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 }, zoom: 12, radiusMeters: NaN });
const root = createRoot(document.createElement("div"));
await act(async () => root.render(<SearchPage />));
expect(state.args[3]).toBe(5000);
await act(async () => root.unmount());
});


it("attributes settled results to the room where that search started", async () => {
state.track.mockClear();
state.params = new URLSearchParams("q=cafe");
state.roomId = "room-a";
state.fetching = true;
useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 }, zoom: 12, radiusMeters: 1000 });
const root = createRoot(document.createElement("div"));
await act(async () => root.render(<SearchPage />));
state.roomId = "room-b";
state.fetching = false;
await act(async () => root.render(<SearchPage />));
expect(state.track).toHaveBeenCalledExactlyOnceWith("search", expect.objectContaining({ room_id: "room-a" }));
await act(async () => root.unmount());
});

it("does not retroactively add a room to a search that started without one", async () => {
state.track.mockClear();
state.roomId = undefined;
state.fetching = true;
const root = createRoot(document.createElement("div"));
await act(async () => root.render(<SearchPage />));
state.roomId = "room-b";
state.fetching = false;
await act(async () => root.render(<SearchPage />));
expect(state.track).toHaveBeenCalledTimes(1);
expect(state.track.mock.lastCall?.[1]).not.toHaveProperty("room_id");
await act(async () => root.unmount());
});
expect(state.replace).toHaveBeenCalledWith(
`${mobile ? "/map" : "/plan"}?q=${encodeURIComponent("원래 검색어")}`,
);
expect(host.textContent).toBe("");
await act(async () => root.unmount());
},
);
Loading