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
16 changes: 16 additions & 0 deletions Design.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@
- 특정 페이지 전용 컴포넌트는 `src/app/**/_components/`에 둔다
- 없으면 Tailwind로 직접 작업한다

### 1-4. Figma 디자인 시스템
[Design System](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System) 파일은 확정된 내용만 모아 둔 곳이다. 퍼블리싱할 때 해당 페이지의 컴포넌트·토큰을 기준으로 맞춘다.

- 00 [Foundation](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=161-9) — Color(Primitive·Semantic), Typography, Icon, Spacing, Shadow, Radius, Grid, Logo
- 01 [Button](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5002-3575) — Button, Button Base (Size·State·Layout)
- 02 [Text Field](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5002-4296) — Text Field Emphasis, Textarea Field, Search Bar, Input Base
- 03·04·05 [Header · Footer · Side Bar](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5001-3046) — Header(Login·Logout), Footer, Side Bar
- 06 [Navigation](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5002-4797) — Bottom Navigation
- 07·08 [Select Field · Dropdown](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5030-540) — Select Field, Select Field_Emphasis, Dropdown, Item
- 09·10 [Date Picker · Time Picker](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5045-885) — Date Picker, Date Cell, Time Picker, Time Picker Modal, Period
- 11 [Tabs](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5138-2632) — Tabs Line, Tabs Chip, Tab Item
- 12 [Toast](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5285-4979) — Toast
- 13 [Alert Dialog](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5619-553) — Alert Dialog
- 14·15 [Chip · Badge](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5624-159) — Chip(Solid·Outline), Badge(Weak·Solid·Outline, 톤 6종)
- [Update Log](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5635-11) — 디자인 시스템 변경 기록

---

## 2. 아이콘의 사용
Expand Down
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
9 changes: 5 additions & 4 deletions src/app/(main)/map/_components/MobileRoutePlaceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type MobileRoutePlaceCardProps = Readonly<{
badgeColor: string;
/** 방금 추가된 카드 — 잠깐 강조한다 */
highlighted: boolean;
/** `45,000 KRW 외 1건` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */
/** 대표 통화 원금액 요약 `30 USD 외 1건` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */
expenseSummary: string | null;
/** 비용을 추가할 수 있는지(비용 권한) */
canAddExpense: boolean;
Expand Down Expand Up @@ -146,11 +146,12 @@ export function MobileRouteExpandedShell({
resetDrag();
}}
onPointerCancel={resetDrag}
className="flex h-8 w-full shrink-0 cursor-pointer touch-pan-x items-end justify-center pb-2"
// 바텀시트 핸들과 같이 위·아래 12px — 아래 12px는 카드 위 여백과 겹쳐 누르는 영역만 넓힌다(테두리 1px 포함 바깥에서 12px)
className="relative z-10 -mb-3 flex w-full shrink-0 cursor-pointer touch-pan-x justify-center pb-3 pt-[11px]"
>
<span aria-hidden className="h-[3px] w-10 rounded-full bg-icon-disabled" />
<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
Loading
Loading