From 8b5b646a9d79fd7ee6bc9ac8205fec2c07120b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Mon, 12 Oct 2026 17:17:43 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix(iphone):=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=ED=92=8B=20=20=ED=8F=B0=ED=8A=B8=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=2016px=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/_components/mobile/MobileMemoSheet.tsx | 2 +- src/components/chat/ChatInputBar.tsx | 2 +- src/components/chat/chat-typography.ts | 17 +++++++++-------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx b/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx index d6bf4add..1217c5bf 100644 --- a/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx +++ b/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx @@ -71,7 +71,7 @@ export function MobileMemoSheet({ disabled={isPending} rows={5} placeholder="메모를 입력하세요" - className="block size-full resize-none bg-transparent px-3 text-body-s-regular text-text outline-none [scrollbar-color:var(--color-border-default)_transparent] [scrollbar-width:thin] placeholder:text-text-subtle" + className="block size-full resize-none bg-transparent px-3 text-body-m-regular text-text outline-none [scrollbar-color:var(--color-border-default)_transparent] [scrollbar-width:thin] placeholder:text-text-subtle" /> diff --git a/src/components/chat/ChatInputBar.tsx b/src/components/chat/ChatInputBar.tsx index 708a34ff..93f964de 100644 --- a/src/components/chat/ChatInputBar.tsx +++ b/src/components/chat/ChatInputBar.tsx @@ -349,7 +349,7 @@ export function ChatInputBar({ placeholder="Shift+Enter로 줄바꿈" className={cn( "box-border min-h-0 w-full flex-1 resize-none overflow-y-auto bg-transparent py-1 leading-normal text-black outline-none placeholder:text-black/40 [scrollbar-color:#d9d9d9_transparent]", - isMinimized ? "text-body-xs-regular" : "text-body-s-regular mobile:text-body-xs-regular", + isMinimized ? "text-body-xs-regular" : "text-body-s-regular mobile:text-body-m-regular", )} style={ aiEnabled diff --git a/src/components/chat/chat-typography.ts b/src/components/chat/chat-typography.ts index 5a59fd61..57db3c52 100644 --- a/src/components/chat/chat-typography.ts +++ b/src/components/chat/chat-typography.ts @@ -5,16 +5,17 @@ const AI_LABEL_WIDTH_CH = 1.5; /** 말풍선·카드·입력 등 본문 (패널 일반 크기) */ const chatTypographyMessage = { - bubble: "text-body-s-regular mobile:text-body-xs-regular leading-relaxed", - systemBody: "text-body-s-regular mobile:text-body-xs-regular leading-relaxed", + bubble: "text-body-s-regular leading-relaxed", + systemBody: "text-body-s-regular leading-relaxed", metaMuted: "text-caption-s-regular leading-relaxed text-dark-gray", wooriSenderLabel: "text-caption-s-regular font-medium leading-relaxed text-primary", - aiRequestBubblePrefix: "text-body-s-emphasis mobile:text-body-xs-emphasis font-semibold leading-relaxed text-secondary", - placeTitle: "text-body-s-emphasis mobile:text-body-xs-emphasis font-semibold leading-snug text-primary-strong", + aiRequestBubblePrefix: "text-body-s-emphasis font-semibold leading-relaxed text-secondary", + placeTitle: "text-body-s-emphasis font-semibold leading-snug text-primary-strong", placeRating: "text-body-xs-emphasis font-medium leading-relaxed text-[#364153]", placeAddress: "text-body-xs-regular leading-relaxed text-[#99A1AF]", input: "text-body-s-regular leading-relaxed", - inputAiLabel: "text-body-s-emphasis leading-relaxed text-secondary font-semibold", + /** 입력창 글자와 같은 크기 — 모바일 입력창은 iOS 확대 방지로 16px */ + inputAiLabel: "text-body-s-emphasis mobile:text-body-m-emphasis leading-relaxed text-secondary font-semibold", } as const; /** 패널 최소화(좁은 폭) */ @@ -60,7 +61,7 @@ export const chatAiBubbleEmphasisClass = "font-semibold text-primary-strong"; export function chatAiBubbleBlockTitleClass(isMinimized: boolean): string { return cn( "font-semibold text-gray-900", - isMinimized ? "text-body-xs-regular leading-snug" : "text-body-s-regular mobile:text-body-xs-regular leading-snug", + isMinimized ? "text-body-xs-regular leading-snug" : "text-body-s-regular leading-snug", ); } @@ -98,7 +99,7 @@ export function chatAiBubblePlaceRecommendationHeadingTitleClass( ): string { return cn( "font-semibold leading-snug text-[#0f172a]", - isMinimized ? "text-body-xs-regular" : "text-body-s-regular mobile:text-body-xs-regular", + isMinimized ? "text-body-xs-regular" : "text-body-s-regular", ); } @@ -108,7 +109,7 @@ export function chatAiBubblePlaceRecommendationHeadingSubtitleClass( ): string { return cn( "font-normal leading-relaxed text-[#1e293b]", - isMinimized ? "text-body-xs-regular" : "text-body-s-regular mobile:text-body-xs-regular", + isMinimized ? "text-body-xs-regular" : "text-body-s-regular", ); } From 99f218972964fb68f7fed6e2937e968769356ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Mon, 12 Oct 2026 17:48:12 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix(map):=EB=A7=B5=20=EB=81=9D=EC=97=90=20?= =?UTF-8?q?=EB=A7=89=ED=9E=88=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/map/Map.tsx | 7 ++++--- src/lib/maps.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index fc98210c..c012f57c 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -19,6 +19,7 @@ import { MAP_MIN_ZOOM, MAP_PAN_RESTRICTION, normalizeGooglePlaceResourceId, + panToWithinWorld, readDestinationLatLngFromSession, viewportSearchRadiusMetersFromBounds, writeDestinationLatLngToSession, @@ -96,7 +97,7 @@ function PlanFocusCameraController({ roomId }: Readonly<{ roomId: string | null useEffect(() => { if (!map || focusSeq === null || lat === undefined || lng === undefined) return; - map.panTo({ lat, lng }); + panToWithinWorld(map, { lat, lng }); }, [map, focusSeq, lat, lng]); return null; @@ -114,7 +115,7 @@ function SelectedPlaceController() { placeSelectionCameraRef.current = "full"; if (mode === "none") return; - map.panTo(selectedPlace.location); + panToWithinWorld(map, selectedPlace.location); if (mode === "full") { map.setZoom(16); } @@ -202,7 +203,7 @@ function MapPreventHorizontalWrap() { const last = lastValidCenterRef.current; if (last) { - map.panTo(last); + panToWithinWorld(map, last); } }); 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/"; From 1e950503e20d1ce29d2de6b1e75ad05e2607acce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Mon, 12 Oct 2026 18:37:58 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix(map):=EB=A8=BC=20=EA=B1=B0=EB=A6=AC=20?= =?UTF-8?q?=ED=95=9C=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=8B=B4=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map/RouteViewCameraController.test.tsx | 45 ++++++++++- .../map/RouteViewCameraController.tsx | 74 ++++++++++++++++--- 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/src/components/map/RouteViewCameraController.test.tsx b/src/components/map/RouteViewCameraController.test.tsx index 2935ba9d..ff29aa6d 100644 --- a/src/components/map/RouteViewCameraController.test.tsx +++ b/src/components/map/RouteViewCameraController.test.tsx @@ -4,7 +4,18 @@ import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { MapRouteView } from "./Map"; -const map = vi.hoisted(() => ({ panTo: vi.fn(), getZoom: () => 15 })); +const map = vi.hoisted(() => ({ + center: { lat: 37.5, lng: 127 }, + panTo: vi.fn(), + setCenter: vi.fn(), + setZoom: vi.fn(), + fitBounds: vi.fn(), + getZoom: () => 15, + getCenter() { + return { lat: () => this.center.lat, lng: () => this.center.lng }; + }, + getDiv: () => ({ clientWidth: 390, clientHeight: 700 }), +})); vi.mock("@vis.gl/react-google-maps", () => ({ useMap: () => map })); import { RouteViewCameraController } from "./RouteViewCameraController"; @@ -16,14 +27,15 @@ const camera: MapRouteView["camera"] = { kind: "pan", seq: 1 }; beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - map.panTo.mockClear(); + map.center = { lat: 37.5, lng: 127 }; + [map.panTo, map.setCenter, map.setZoom, map.fitBounds].forEach((fn) => fn.mockClear()); host = document.createElement("div"); root = createRoot(host); }); afterEach(async () => { await act(async () => root.unmount()); }); -async function render(focusedItemId: number, locations: MapRouteView["locations"]) { - await act(async () => root.render( root.render()); } @@ -40,3 +52,28 @@ it("삭제 후 선택이 다음 장소로 바뀌면 같은 카메라 요청에 await render(2, [second]); expect(map.panTo).toHaveBeenCalledWith(expect.objectContaining({ lng: 128 })); }); + +const incheon = { itemId: 10, lat: 37.46, lng: 126.44 }; +const sanFrancisco = { itemId: 11, lat: 37.76, lng: -122.41 }; +const santaClara = { itemId: 12, lat: 37.32, lng: -121.99 }; +const losAngeles = { itemId: 13, lat: 34.05, lng: -118.24 }; + +it("날짜변경선을 넘는 쪽이 가까운 장소로는 경계에 막히지 않게 바로 옮긴다", async () => { + await render(11, [incheon, sanFrancisco]); + expect(map.panTo).not.toHaveBeenCalled(); + expect(map.setCenter).toHaveBeenCalledWith(expect.objectContaining({ lng: -122.41 })); +}); + +it("일차가 줌 9 한 화면에 안 들어가면 고른 장소 쪽 장소만 맞춘다", async () => { + await render(11, [incheon, sanFrancisco, losAngeles, santaClara], { kind: "fit", seq: 1 }); + expect(map.fitBounds).toHaveBeenCalledWith( + expect.objectContaining({ west: -122.41, east: -121.99 }), + expect.anything(), + ); +}); + +it("고른 장소 말고 함께 들어가는 장소가 없으면 그 장소를 가까이 보여 준다", async () => { + await render(11, [incheon, sanFrancisco, losAngeles], { kind: "fit", seq: 1 }); + expect(map.fitBounds).not.toHaveBeenCalled(); + expect(map.setZoom).toHaveBeenCalledWith(15); +}); diff --git a/src/components/map/RouteViewCameraController.tsx b/src/components/map/RouteViewCameraController.tsx index 598e6823..33229cf8 100644 --- a/src/components/map/RouteViewCameraController.tsx +++ b/src/components/map/RouteViewCameraController.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { useMap } from "@vis.gl/react-google-maps"; +import { haversineMeters, panToWithinWorld } from "@/lib/maps"; import type { MapRouteView } from "./Map"; /** 접힌 카드·일차 탭·X가 가리는 지도 아래쪽 높이 */ @@ -11,6 +12,8 @@ const ROUTE_VIEW_EXPANDED_COVER_RATIO = 0.72; const ROUTE_VIEW_EDGE_PADDING = 40; /** 장소 하나만 볼 때(장소 하나뿐인 일차·일정 카드로 들어올 때)의 줌 — 주변 길이 보이는 정도 */ const ROUTE_VIEW_SINGLE_PLACE_ZOOM = 15; +/** 일차 전체를 볼 때 가장 넓게 줄이는 줌 — 이보다 줄여야 함께 보이는 장소는 빼고 고른 장소 쪽만 맞춘다(폰에서 사방 약 80km — 도심과 공항 정도) */ +const ROUTE_VIEW_FIT_MIN_ZOOM = 9; /** * 가린 영역을 뺀 나머지의 가운데에 `target`이 오도록 옮긴다. @@ -24,13 +27,51 @@ function panToVisibleCenter( zoom = map.getZoom(), ) { if (zoom === undefined || coveredPx <= 0) { - map.panTo(target); + panToWithinWorld(map, target); return; } const lngDegreesPerPx = 360 / (256 * 2 ** zoom); const latDegreesPerPx = lngDegreesPerPx * Math.cos((target.lat * Math.PI) / 180); // 지도 중심을 아래로 coveredPx/2만큼 내리면 target이 보이는 영역 가운데로 올라온다 - map.panTo({ lat: target.lat - (coveredPx / 2) * latDegreesPerPx, lng: target.lng }); + panToWithinWorld(map, { lat: target.lat - (coveredPx / 2) * latDegreesPerPx, lng: target.lng }); +} + +/** 줌 0의 메르카토르 세계 좌표(0~256px) */ +function toWorldPx({ lat, lng }: google.maps.LatLngLiteral) { + const sin = Math.min(Math.max(Math.sin((lat * Math.PI) / 180), -0.9999), 0.9999); + return { x: 256 * (0.5 + lng / 360), y: 256 * (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) }; +} + +/** + * `ROUTE_VIEW_FIT_MIN_ZOOM`에서 `widthPx`×`heightPx` 안에 함께 들어가는 장소 — `anchor`에서 가까운 순으로 채운다. + * 멀리 떨어진 일차(인천공항 → 샌프란시스코 등)를 통째로 맞추면 너무 넓어지거나 두 곳의 가운데(바다·다른 대륙)만 보이므로 + * 고른 장소 쪽만 남긴다. + */ +function placesFittingAround( + anchor: google.maps.LatLngLiteral, + locations: readonly T[], + widthPx: number, + heightPx: number, +) { + const scale = 2 ** ROUTE_VIEW_FIT_MIN_ZOOM; + const byDistance = [...locations].sort((a, b) => haversineMeters(anchor, a) - haversineMeters(anchor, b)); + const fitting: T[] = []; + let box = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }; + for (const location of byDistance) { + const { x, y } = toWorldPx(location); + const next = { + minX: Math.min(box.minX, x), + maxX: Math.max(box.maxX, x), + minY: Math.min(box.minY, y), + maxY: Math.max(box.maxY, y), + }; + if (fitting.length > 0 && ((next.maxX - next.minX) * scale > widthPx || (next.maxY - next.minY) * scale > heightPx)) { + continue; + } + box = next; + fitting.push(location); + } + return fitting; } /** 지도 왼쪽·위쪽에서 `target`까지의 거리(px) — 지도를 아직 그리지 않았으면 null */ @@ -110,19 +151,28 @@ export function RouteViewCameraController({ panToVisibleCenter(map, toLatLng(target), coveredPx, ROUTE_VIEW_SINGLE_PLACE_ZOOM); return; } - if (locations.length === 1) { + const padding = { top: 56, right: ROUTE_VIEW_EDGE_PADDING, bottom: coveredPx + 16, left: ROUTE_VIEW_EDGE_PADDING }; + const { clientWidth, clientHeight } = map.getDiv(); + const anchor = target ?? locations[0]!; + const shown = placesFittingAround( + anchor, + locations, + clientWidth - padding.left - padding.right, + clientHeight - padding.top - padding.bottom, + ); + if (shown.length === 1) { map.setZoom(ROUTE_VIEW_SINGLE_PLACE_ZOOM); - panToVisibleCenter(map, toLatLng(locations[0]!), coveredPx, ROUTE_VIEW_SINGLE_PLACE_ZOOM); + panToVisibleCenter(map, toLatLng(anchor), coveredPx, ROUTE_VIEW_SINGLE_PLACE_ZOOM); return; } - const bounds = new google.maps.LatLngBounds(); - locations.forEach((l) => bounds.extend(toLatLng(l))); - map.fitBounds(bounds, { - top: 56, - right: ROUTE_VIEW_EDGE_PADDING, - bottom: coveredPx + 16, - left: ROUTE_VIEW_EDGE_PADDING, - }); + // `LatLngBounds.extend`는 날짜변경선을 넘는 쪽이 좁으면 그쪽 범위를 잡는데, 가로 래핑을 막은 지도에선 + // 경계에 막혀 한쪽만 보인다 — 넘지 않는 범위를 리터럴로 넘긴다 + const lats = shown.map((l) => l.lat); + const lngs = shown.map((l) => l.lng); + map.fitBounds( + { west: Math.min(...lngs), east: Math.max(...lngs), south: Math.min(...lats), north: Math.max(...lats) }, + padding, + ); }, [map, camera.kind, camera.seq, locations, focusedItemId, expanded]); return null; From e9994399561df35e92cec0afe9a566c83fe023ec Mon Sep 17 00:00:00 2001 From: KangShingyu <103213494+KangShinGyu98@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:52:27 +0900 Subject: [PATCH 4/8] Merge pull request #233 from uttae/feature/plan-card-design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feature(plan): PC일정 페이지 일정카드 디자인 반영 --- Design.md | 16 +++ .../map/_components/MobileRoutePlaceCard.tsx | 2 +- .../_components/useMobileRouteView.test.tsx | 89 ++++++++++++-- .../map/_components/useMobileRouteView.tsx | 4 +- .../itinerary/PlanDaySection.test.tsx | 11 +- .../_components/itinerary/PlanDaySection.tsx | 37 +++--- .../itinerary/PlanItemMemoForm.tsx | 14 +-- .../_components/itinerary/PlanItinerary.tsx | 2 + .../_components/itinerary/PlanPageView.tsx | 10 +- .../_components/itinerary/PlanPlaceCard.tsx | 116 +++++++----------- .../itinerary/PlanPlaceCardParts.tsx | 9 +- .../itinerary/PlanScheduleDayBlock.tsx | 12 +- .../mobile/MobilePlanDaySection.tsx | 4 +- .../mobile/MobilePlanItinerary.tsx | 4 +- .../mobile/MobilePlanPlaceCard.tsx | 10 +- .../travel-time/PlanTravelTime.tsx | 1 + .../travel-time/TravelDirectionsCard.tsx | 12 +- src/assets/icons/index.tsx | 2 + src/assets/icons/memo-add.svg | 5 + .../expenses/ExpenseProvider.test.tsx | 58 +++++++-- src/components/expenses/ExpenseProvider.tsx | 17 ++- .../layout/MainContentScrollArea.tsx | 9 +- src/lib/layout-tokens.ts | 28 +++-- src/lib/plan/mobilePlanFormat.test.ts | 43 +++---- src/lib/plan/mobilePlanFormat.ts | 7 +- src/lib/plan/scheduleMerge.ts | 2 + src/lib/plan/tripRange.ts | 7 ++ src/lib/plan/types.ts | 3 + 28 files changed, 335 insertions(+), 199 deletions(-) create mode 100644 src/assets/icons/memo-add.svg diff --git a/Design.md b/Design.md index ed96d832..2f0bf543 100644 --- a/Design.md +++ b/Design.md @@ -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. 아이콘의 사용 diff --git a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx index 9ea90a49..98122c1d 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx @@ -19,7 +19,7 @@ type MobileRoutePlaceCardProps = Readonly<{ badgeColor: string; /** 방금 추가된 카드 — 잠깐 강조한다 */ highlighted: boolean; - /** `45,000 KRW 외 1건` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */ + /** 대표 통화 원금액 요약 `30 USD 외 1건` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */ expenseSummary: string | null; /** 비용을 추가할 수 있는지(비용 권한) */ canAddExpense: boolean; diff --git a/src/app/(main)/map/_components/useMobileRouteView.test.tsx b/src/app/(main)/map/_components/useMobileRouteView.test.tsx index 996ec086..3db17c64 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.test.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { act, useEffect } from "react"; +import { act, useEffect, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { expect, it, vi } from "vitest"; @@ -10,6 +10,9 @@ const fixtures = vi.hoisted(() => ({ { id: "item-101", itemId: 101, title: "첫 장소", location: { lat: 37.5, lng: 127 } }, { id: "item-102", itemId: 102, title: "먼 장소", location: { lat: 37.7, lng: 127.2 } }, ], + expenses: [] as import("@/lib/api/rooms/expenses").Expense[], + open: vi.fn(), + openScope: vi.fn(), setSelectedPlace: vi.fn(), router: { replace: vi.fn() }, })); @@ -22,7 +25,7 @@ vi.mock("@/hooks/useRooms", () => ({ })); vi.mock("@/hooks/usePrefetchScheduleRoutes", () => ({ usePrefetchScheduleRoutes: () => true })); vi.mock("@/components/expenses/ExpenseProvider", () => ({ - useExpenseContext: () => ({ list: { data: [] }, canManage: true }), + useExpenseContext: () => ({ list: { data: fixtures.expenses }, canManage: true, open: fixtures.open, openScope: fixtures.openScope }), })); vi.mock("@/contexts/SelectedPlaceContext", () => ({ useSelectedPlace: () => ({ setSelectedPlace: fixtures.setSelectedPlace }), @@ -32,20 +35,17 @@ vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ readSchedulePlanPlacesFromCach vi.mock("@/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch", () => ({ useMobileAddPlaceSearch: () => ({ recentlyAddedItemId: null, screen: null }), })); -vi.mock("@/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets", () => ({ +vi.mock("@/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets", async (importOriginal) => ({ + ...await importOriginal(), MobilePlanPlaceSheets: () => null, - openPlaceExpenses: vi.fn(), })); vi.mock("@/components/map/MapCloseButton", () => ({ MapCloseButton: () => null })); vi.mock("./MobileRouteCandidateCard", () => ({ MobileRouteCandidateCard: () => null })); -vi.mock("./MobileRouteCardCarousel", () => ({ MobileRouteCardCarousel: () => null })); -vi.mock("./MobileRoutePlaceDetail", () => ({ MobileRoutePlaceDetail: () => null })); -vi.mock("./MobileRoutePlaceCard", () => ({ - MobileRouteEmptyDayCard: () => null, - MobileRouteMessageCard: () => null, - MobileRoutePlaceCard: () => null, - MobileRoutePlaceCardSkeleton: () => null, +vi.mock("./MobileRouteCardCarousel", () => ({ + MobileRouteCardCarousel: ({ cards }: { cards: { key: string; node: ReactNode }[] }) => <>{cards.map(card =>
{card.node}
)}, })); +vi.mock("./MobileRoutePlaceDetail", () => ({ MobileRoutePlaceDetail: () => null })); + import { readMapRouteParams } from "@/lib/mobile-view"; import { scheduleItemsQueryKey } from "@/lib/query-keys"; @@ -85,3 +85,70 @@ it.each([ host.remove(); } }); + + +import { MobilePlanItinerary } from "@/app/(main)/plan/_components/mobile/MobilePlanItinerary"; +import { MobilePlanDragProvider } from "@/app/(main)/plan/_components/mobile/mobilePlanDrag"; +vi.mock("@/app/(main)/plan/_components/travel-time/PlanTravelTime", () => ({ PlanTravelTime: () => null })); + +it.each([ + { rows: [["KRW", "1000"]], label: "1,000 KRW" }, + { rows: [["JPY", "3600"]], label: "3,600 JPY" }, + { rows: [["USD", "10"], ["USD", "20"], ["KRW", "1000"]], label: "30 USD 외 1건" }, + { rows: [["USD", "10.10"], ["USD", "20.20"]], label: "30.30 USD" }, + { rows: [["JPY", "0"]], label: "0 JPY" }, + { rows: [["USD", "12345678901234567890.12"], ["EUR", "1"]], label: "12,345,678,901,234,567,890.12 USD 외 1건" }, + { rows: [], label: null }, +].flatMap(testCase => ["itinerary", "route"].map(mode => ({ ...testCase, mode }))))("$mode shows $label with scoped list/empty-add behavior", async ({ rows, label, mode }) => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const originalScrollTo = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTo"); + Object.defineProperty(HTMLElement.prototype, "scrollTo", { configurable: true, value: vi.fn() }); + fixtures.expenses = rows.map(([currency, totalAmount], id) => ({ + id, version: 1, expenseGroup: "TRIP_DAY", scheduleId: 10, scheduleItemId: 101, + currency, totalAmount, category: "OTHER", memo: null, name: null, + payerUserIds: [1], participantUserIds: [1], createdAt: "", updatedAt: "", + })); + // Another place must not affect either summary or empty/add decisions. + fixtures.expenses.push({ id: 100, version: 1, expenseGroup: "TRIP_DAY", scheduleId: 10, scheduleItemId: 102, currency: "KRW", totalAmount: "999999", category: "OTHER", memo: null, name: null, payerUserIds: [1], participantUserIds: [1], createdAt: "", updatedAt: "" }); + fixtures.open.mockClear(); + fixtures.openScope.mockClear(); + window.history.replaceState(null, "", "/map?view=route&day=1&item=101"); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(scheduleItemsQueryKey("room-1", 10), fixtures.places); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + function Route() { return useMobileRouteView({ active: true, day: 1, initialItemId: 101 }).overlay; } + const drag = { editing: false, dragDisabled: false, pending: null, dropTargetScheduleId: null, + registerList: vi.fn(), registerRow: vi.fn(), registerPlaces: vi.fn(), startDrag: vi.fn(), rowStyle: () => undefined }; + try { + await act(async () => root.render( + {mode === "route" ? : } + )); + const button = host.querySelector(`button[aria-label="첫 장소 비용 ${label ? "보기" : "추가"}"]`); + if (label) { + expect(button?.textContent).toContain(label); + const descriptionId = button?.getAttribute("aria-describedby"); + expect(descriptionId).toBeTruthy(); + expect(document.getElementById(descriptionId!)?.textContent).toBe(label); + const expenseButtons = [...host.querySelectorAll("button[aria-describedby]")]; + expect(new Set(expenseButtons.map(node => node.getAttribute("aria-describedby"))).size).toBe(expenseButtons.length); + await act(async () => button!.click()); + expect(fixtures.openScope).toHaveBeenLastCalledWith({ scheduleId: 10, scheduleItemId: 101, label: "첫 장소" }); + expect(fixtures.open).not.toHaveBeenCalled(); + } else if (mode === "route") { + expect(button).not.toBeNull(); + await act(async () => button!.click()); + expect(fixtures.open).toHaveBeenCalledWith({ scheduleId: 10, scheduleItemId: 101 }); + expect(fixtures.openScope).not.toHaveBeenCalled(); + } else { + // Itinerary keeps empty expense rows hidden; its action sheet owns adding. + expect(button).toBeNull(); + } + } finally { + await act(async () => root.unmount()); + queryClient.clear(); host.remove(); fixtures.expenses = []; + if (originalScrollTo) Object.defineProperty(HTMLElement.prototype, "scrollTo", originalScrollTo); + else Reflect.deleteProperty(HTMLElement.prototype, "scrollTo"); + } +}); diff --git a/src/app/(main)/map/_components/useMobileRouteView.tsx b/src/app/(main)/map/_components/useMobileRouteView.tsx index 38d44d87..51e30d53 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.tsx @@ -16,7 +16,7 @@ import { expensesInScope } from "@/lib/expenses/expense-scope"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; import { buildMapRouteHref, MOBILE_MAP_PATH } from "@/lib/mobile-view"; import { insertAnchorAfterItem } from "@/lib/plan/insertPosition"; -import { formatMonthDayKo, summarizeExpensesForMobile } from "@/lib/plan/mobilePlanFormat"; +import { formatMonthDayKo, summarizePlaceExpenses } from "@/lib/plan/mobilePlanFormat"; import { scheduleIdsToRouteColors, sortedScheduleIdsForRouteColors, @@ -401,7 +401,7 @@ export function useMobileRouteView({ orderNumber={entry.orderNumber} badgeColor={dayColor(entry.scheduleId)} highlighted={addPlace.recentlyAddedItemId === entry.itemId} - expenseSummary={summarizeExpensesForMobile(itemExpenses(entry.scheduleId, entry.itemId))} + expenseSummary={summarizePlaceExpenses(itemExpenses(entry.scheduleId, entry.itemId))} canAddExpense={expenses.canManage} expanded={isExpanded} onExpand={() => { diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx index a285e7ec..c1d592ed 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx @@ -5,13 +5,13 @@ import { describe, expect, it } from "vitest"; import { PlanDaySection } from "./PlanDaySection"; describe("PlanDaySection", () => { - it("펼침 토글 아이콘을 제목 왼쪽에 배치한다", () => { + it("펼침 토글 아이콘을 날짜·일차 배지 뒤에 배치한다", () => { const html = renderToStaticMarkup( createElement( PlanDaySection, { title: "1일차", - subtitle: "7월 16일 (목)", + subtitle: "07.16(목)", }, createElement("div", null, "일정"), ), @@ -24,8 +24,11 @@ describe("PlanDaySection", () => { expect(triggerMarkup).toBeDefined(); expect(triggerMarkup).toContain('class="shrink-0 text-dark-gray"'); expect(triggerMarkup).not.toContain("ml-auto"); - expect(triggerMarkup!.indexOf("lucide-chevron-up")).toBeLessThan( - triggerMarkup!.indexOf("7월 16일 (목)"), + expect(triggerMarkup!.indexOf("lucide-chevron-up")).toBeGreaterThan( + triggerMarkup!.indexOf("1일차"), + ); + expect(triggerMarkup!.indexOf("07.16(목)")).toBeLessThan( + triggerMarkup!.indexOf("1일차"), ); expect(html).not.toContain("absolute left-1/2"); }); diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx index e72ba82d..b13f43ff 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx @@ -170,7 +170,7 @@ export function PlanDaySection({
+
+

+ {subtitle || title} +

+ {subtitle ? ( + // 디자인 시스템 Badge / Weak / Brand / md — 배경색은 시맨틱 토큰이 없어 원시 토큰을 쓴다 + + {title} + + ) : null} +
{expanded ? ( @@ -215,18 +227,6 @@ export function PlanDaySection({ )} -
-
-

- {subtitle || title} -

- {subtitle ? ( - - {title} - - ) : null} -
-
{showScheduleMenu ? (
@@ -315,7 +315,7 @@ export function PlanDaySection({
{summaryAction || trackedSid !== undefined ? (
{summaryAction}
@@ -328,7 +328,7 @@ export function PlanDaySection({ toggleRouteVisible(); }} aria-checked={routeVisible} - className="flex min-h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 text-label-s-regular text-dark-gray transition-colors hover:bg-bubble-gray/60 focus-visible:outline-2 focus-visible:outline-primary" + className="flex min-h-8 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 text-label-xs-regular text-text-subtle transition-colors hover:bg-bubble-gray/60 focus-visible:outline-2 focus-visible:outline-primary" > 지도에 경로 표시