diff --git a/Design.md b/Design.md index 62724f5f..ed96d832 100644 --- a/Design.md +++ b/Design.md @@ -58,6 +58,15 @@ export const MapPinIcon = createIcon(MapPinSvg, "MapPinIcon"); // 아래쪽 export 구역 ``` +### 2-1-1. Figma에 추가 요청할 아이콘 +Figma 디자인 시스템 Icon에 없어 임시로 Tabler Icons(outline)에서 받아 쓰는 아이콘이다. 디자이너가 디자인 시스템에 추가하면 원본으로 바꾸고 이 목록에서 지운다. +- 파일 형식은 2-1의 규칙(24×24 viewBox, `currentColor`, stroke 1.8)에 맞춰 수정해 넣는다 + +| 아이콘 | 파일 | 출처 | 쓰는 곳 | +|---|---|---|---| +| 경로 보기 | `route.svg` (`RouteIcon`) | Tabler `route` | 모바일 지도 경로 보기 버튼 | +| 보내기(종이비행기) | `send.svg` (`SendIcon`) | Tabler `send` | 헤더 초대하기 (PR #220) | + ### 2-2. 아이콘 사용 ```tsx import { MapPinIcon } from "@/assets/icons"; diff --git a/src/app/(main)/chat/page.test.tsx b/src/app/(main)/chat/page.test.tsx new file mode 100644 index 00000000..78b6b128 --- /dev/null +++ b/src/app/(main)/chat/page.test.tsx @@ -0,0 +1,24 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +vi.mock("@/contexts/MobileViewContext", () => ({ useMobileView: () => ({ isMobileDevice: false }) })); +vi.mock("@/components/chat", () => ({ ChatPanel: ({ inline }: { inline?: boolean }) =>
})); + +import ChatPage from "./page"; +import { useChatPanelStore } from "@/stores/chat-panel-store"; + +it("뒤로가기·주소 입력으로 들어와도 최소화를 풀고 최대화 채팅을 그린다", async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + useChatPanelStore.setState({ minimized: true }); + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + try { + await act(async () => root.render()); + expect(useChatPanelStore.getState().minimized).toBe(false); + expect(host.querySelector('[data-chat][data-inline="true"]')).not.toBeNull(); + } finally { + await act(async () => root.unmount()); host.remove(); + } +}); diff --git a/src/app/(main)/chat/page.tsx b/src/app/(main)/chat/page.tsx new file mode 100644 index 00000000..cfa2aebc --- /dev/null +++ b/src/app/(main)/chat/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect } from "react"; + +import { ChatPanel } from "@/components/chat"; +import { useMobileView } from "@/contexts/MobileViewContext"; +import { useChatPanelStore } from "@/stores/chat-panel-store"; + +/** `/chat` — 최대화 채팅 화면. PC는 지도 옆 본문 자리에, 모바일은 전체 화면으로 그린다 */ +export default function ChatPage() { + const { isMobileDevice } = useMobileView(); + + // 어떤 경로로 들어오든(사이드바·뒤로가기·주소 입력) 채팅에 들어오면 최소화를 푼다. + // 그래야 메뉴로 나갈 때 최소화 창이 다시 떠오르지 않는다. + useEffect(() => { + useChatPanelStore.getState().setMinimized(false); + }, []); + + return isMobileDevice ? : ; +} diff --git a/src/app/(main)/map/_components/MobileRouteCandidateCard.tsx b/src/app/(main)/map/_components/MobileRouteCandidateCard.tsx new file mode 100644 index 00000000..02418ec1 --- /dev/null +++ b/src/app/(main)/map/_components/MobileRouteCandidateCard.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useState } from "react"; + +import { CloseIcon, PlusIcon, ReactionStarIcon } from "@/assets/icons"; +import { DashedBorder } from "@/components/mobile/DashedPlaceholderBox"; +import { usePlaceDetailData } from "@/components/place/usePlaceDetailData"; +import { cn } from "@/lib/utils"; + +import { MobileRouteExpandedShell } from "./MobileRoutePlaceCard"; +import { MobileRoutePlaceDetail } from "./MobileRoutePlaceDetail"; + +/** + * 경로 보기에서 지도의 Google 장소를 누르면 카드 줄의 넣을 자리(고른 카드 바로 뒤)에 끼우는 후보 카드. + * 일정 장소 카드와 같은 배치(배지·이름·분류)를 쓰되, 아직 넣지 않은 장소라 점선 테두리와 "+" 배지로 구분한다. + * 카드를 누르면 다른 경로 카드처럼 사진·홈·리뷰 상세로 펼친다. + */ +export function MobileRouteCandidateCard({ + googlePlaceId, + badgeColor, + onAdd, + onClose, + expanded, + onExpand, + onCollapse, + detailActive, +}: Readonly<{ + googlePlaceId: string; + /** 넣을 일차 색 */ + badgeColor: string; + /** 추가에 성공하면 true — 실패하면 카드를 남겨 다시 누를 수 있게 한다 */ + onAdd: () => Promise; + onClose: () => void; + expanded: boolean; + onExpand: () => void; + onCollapse: () => void; + /** 가운데 카드일 때만 펼친 상세를 불러온다 */ + detailActive: boolean; +}>) { + const { data: detail, isLoading } = usePlaceDetailData(googlePlaceId); + const [adding, setAdding] = useState(false); + const name = detail?.name?.trim() || "장소"; + const category = detail?.primaryTypeDisplayName?.trim() ?? ""; + const address = detail?.formattedAddress?.trim() ?? ""; + + async function handleAdd() { + if (adding) return; + setAdding(true); + const added = await onAdd(); + // 성공하면 카드가 사라지므로 실패했을 때만 되돌린다 + if (!added) setAdding(false); + } + + // 접힌 카드는 넘겨 보는 줄 높이(가장 긴 카드)를 채우고, 정보는 위·추가 버튼은 아래에 붙인다 + const card = ( +
+ {expanded ? null : ( + +
+
+ + + + ); + + if (!expanded) { + return ( + // 다른 장소 카드와 같은 배경에 점선 테두리만 달리해 아직 넣지 않은 자리임을 보인다 +
+ + {card} +
+ ); + } + + return ( + + {card} + + + ); +} + +function CandidateMetadata({ category, address, rating }: Readonly<{ + category: string; + address: string; + rating: number | null | undefined; +}>) { + return ( +

+ {category ? {category} : null} + {rating != null ? ( + + {category ? · : null} + + + {rating.toFixed(1)} + + + ) : null} + {address ? ( + <> + {category || rating != null ? ( + · + ) : null} + {address} + + ) : null} +

+ ); +} diff --git a/src/app/(main)/map/_components/MobileRouteCardCarousel.tsx b/src/app/(main)/map/_components/MobileRouteCardCarousel.tsx new file mode 100644 index 00000000..f7a1a87d --- /dev/null +++ b/src/app/(main)/map/_components/MobileRouteCardCarousel.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useEffect, useRef, type ReactNode } from "react"; + +/** 손을 뗀 뒤 스냅이 멈출 때까지 기다렸다가 고른 카드를 알린다 — 넘기는 중 지도가 여러 번 움직이지 않게 */ +const SCROLL_SETTLE_MS = 120; + +type CarouselCard = { key: string; node: ReactNode }; + +/** + * 카드를 가로로 넘겨 본다 — 가운데 카드가 고른 카드이고 양옆 카드가 조금 보인다. + * 카드를 넘기면 `onFocusChange`로 알리고, 바깥(지도 핀·일차 탭·처음 진입·삭제)에서 고른 카드가 바뀌면 그 카드로 옮긴다. + * 넘기는 중에는 `onPreviewChange`로 지금 가운데 카드를 바로 알린다 — 일차 탭처럼 가벼운 표시만 먼저 바꾸는 데 쓴다. + */ +export function MobileRouteCardCarousel({ + cards, + focusedKey, + onFocusChange, + onPreviewChange, +}: Readonly<{ + cards: readonly CarouselCard[]; + focusedKey: string | null; + onFocusChange: (index: number) => void; + /** 넘기는 중 가운데 카드(멈추면 null) */ + onPreviewChange?: (index: number | null) => void; +}>) { + const containerRef = useRef(null); + const settleTimerRef = useRef(null); + /** 카드를 넘겨서 고른 카드 — 이 경우엔 다시 스크롤하지 않는다(한 번 쓰고 비운다) */ + const fromScrollRef = useRef(null); + /** 코드로 옮기는 중인 카드 — 도착할 때까지 스크롤로 고르지 않는다. 사용자가 카드 줄을 만지면 버린다 */ + const scrollTargetRef = useRef(null); + const scrolledOnceRef = useRef(false); + const previewRef = useRef(null); + const setPreview = (index: number | null) => { + if (previewRef.current === index) return; + previewRef.current = index; + onPreviewChange?.(index); + }; + + const focusedIndex = cards.findIndex((card) => card.key === focusedKey); + + useEffect(() => { + const el = containerRef.current; + if (!el || focusedIndex < 0) return; + // 넘겨서 고른 직후엔 이미 그 자리에 있다. 기록은 비워 두어, 나중에 코드로 같은 카드를 다시 고르거나 + // 카드가 끼워지고 빠져 자리가 밀려도 그 카드로 옮기게 한다 + const fromScroll = fromScrollRef.current === focusedKey; + fromScrollRef.current = null; + if (fromScroll) return; + const child = el.children[focusedIndex] as HTMLElement | undefined; + if (!child) return; + const left = child.offsetLeft - (el.clientWidth - child.clientWidth) / 2; + if (Math.abs(el.scrollLeft - left) < 2) return; + scrollTargetRef.current = focusedIndex; + el.scrollTo({ left, behavior: scrolledOnceRef.current ? "smooth" : "auto" }); + scrolledOnceRef.current = true; + }, [focusedIndex, focusedKey]); + + useEffect( + () => () => { + if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current); + }, + [], + ); + + function nearestIndex(el: HTMLDivElement) { + const center = el.scrollLeft + el.clientWidth / 2; + let best = 0; + let bestDistance = Number.POSITIVE_INFINITY; + Array.from(el.children).forEach((node, index) => { + const child = node as HTMLElement; + const distance = Math.abs(child.offsetLeft + child.clientWidth / 2 - center); + if (distance < bestDistance) { + bestDistance = distance; + best = index; + } + }); + return best; + } + + function handleScroll() { + // 코드로 옮기는 중엔 목적지가 이미 골라져 있으니 지나가는 카드를 알리지 않는다 + const current = containerRef.current; + if (current && scrollTargetRef.current === null) setPreview(nearestIndex(current)); + if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current); + settleTimerRef.current = window.setTimeout(() => { + settleTimerRef.current = null; + const el = containerRef.current; + if (!el) return; + const index = nearestIndex(el); + // 같은 타이머 안이라 아래 onFocusChange와 함께 한 번에 그려진다 + setPreview(null); + // 코드로 옮기는 중이면 이미 고른 카드다 — 도착했을 때만 표시를 지운다(도중에 멈칫한 건 무시) + if (scrollTargetRef.current !== null) { + if (scrollTargetRef.current === index) scrollTargetRef.current = null; + return; + } + const key = cards[index]?.key; + if (key === undefined || key === focusedKey) return; + fromScrollRef.current = key; + onFocusChange(index); + }, SCROLL_SETTLE_MS); + } + + return ( +
{ + scrollTargetRef.current = null; + }} + // relative: 카드 위치(offsetLeft)를 카드 줄 기준으로 재서 브라우저마다 같은 값이 나오게 한다 + className="pointer-events-auto relative flex h-full snap-x snap-mandatory gap-2 overflow-x-auto overscroll-x-contain px-[18px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" + > + {cards.map((card) => ( + // 카드 폭은 좌우 여백(18px)을 뺀 전체 — 393px 화면에서 Figma처럼 357px, 옆 카드가 10px 보인다 +
+ {card.node} +
+ ))} +
+ ); +} diff --git a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx new file mode 100644 index 00000000..9ea90a49 --- /dev/null +++ b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; + +import { MobilePlanPlaceCard } from "@/app/(main)/plan/_components/mobile/MobilePlanPlaceCard"; +import { DashedBorder, DashedPlaceholderBox } from "@/components/mobile/DashedPlaceholderBox"; +import type { PlanPlace } from "@/lib/plan/types"; +import { cn } from "@/lib/utils"; + +/** 넘겨 보는 카드 칸 — 높이는 넘겨 보는 줄이 정한다(접힘: 가장 긴 카드, 펼침: 지도의 70%) */ +const CARD_CLASS = "relative h-full overflow-hidden rounded-md border border-border-subtle bg-fill-subtle"; +/** 펼친 카드를 이만큼 아래로 끌면 접는다 */ +const COLLAPSE_DRAG_PX = 56; + +type MobileRoutePlaceCardProps = Readonly<{ + place: PlanPlace; + orderNumber: number; + /** 지도 핀·경로선과 같은 일차 색 */ + badgeColor: string; + /** 방금 추가된 카드 — 잠깐 강조한다 */ + highlighted: boolean; + /** `45,000 KRW 외 1건` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */ + expenseSummary: string | null; + /** 비용을 추가할 수 있는지(비용 권한) */ + canAddExpense: boolean; + expanded: boolean; + /** 접힌 카드 본문 — 상세를 펼친다 */ + onExpand: () => void; + /** 펼친 카드의 ⌄·아래로 끌기 — 접는다 */ + onCollapse: () => void; + onOpenActions: () => void; + onOpenExpenses: () => void; + onEditMemo: () => void; + onEditTime: () => void; + /** 펼쳤을 때 카드 아래에 이어지는 상세 */ + detail: ReactNode; +}>; + +/** + * 경로 보기 장소 카드 — 일정 화면의 장소 카드(`MobilePlanPlaceCard`)를 그대로 쓴다(시간·비용·메모·⋮ 동작 같음). + * 본문을 누르면 펼쳐 카드 아래에 상세를 잇고, 손잡이·⌄ 줄만 위에 남긴 채 카드부터 상세까지 함께 스크롤한다. + */ +export function MobileRoutePlaceCard({ + place, + orderNumber, + badgeColor, + highlighted, + expenseSummary, + canAddExpense, + expanded, + onExpand, + onCollapse, + onOpenActions, + onOpenExpenses, + onEditMemo, + onEditTime, + detail, +}: MobileRoutePlaceCardProps) { + const card = ( + + ); + + if (!expanded) return card; + + return ( + + {card} + {detail} + + ); +} + +/** + * 펼친 경로 카드의 틀 — 손잡이·⌄ 줄만 위에 남기고 나머지는 함께 스크롤한다. + * 손잡이 줄을 누르거나 아래로 끌면 접는다. 장소 카드와 지도에서 고른 후보 카드가 함께 쓴다. + */ +export function MobileRouteExpandedShell({ + onCollapse, + tentative = false, + children, +}: Readonly<{ + onCollapse: () => void; + /** 아직 일정에 넣지 않은 후보 — 접힌 후보 카드처럼 점선 테두리 */ + tentative?: boolean; + children: ReactNode; +}>) { + // 가로 넘기기는 브라우저 스크롤에 맡긴다(`touch-action: pan-x`) + const dragStartYRef = useRef(null); + // 손을 뗄 때는 렌더 전 최신 거리가 필요해 ref에도 둔다(화면 이동은 상태로 그린다) + const dragOffsetRef = useRef(0); + const [dragOffset, setDragOffset] = useState(0); + // 끌다가 놓으면 이어서 click이 오므로, 끈 경우엔 그 click으로 접지 않는다(덜 끌었으면 제자리로) + const draggedRef = useRef(false); + const resetDrag = () => { + dragStartYRef.current = null; + dragOffsetRef.current = 0; + setDragOffset(0); + }; + + return ( +
0 ? { transform: `translateY(${dragOffset}px)` } : undefined} + > + {/* 카드가 테두리를 자르므로(overflow-hidden) 반 픽셀 안쪽에 그린다 */} + {tentative ? : null} + +
{children}
+
+ ); +} + +/** 처음 불러오는 동안 — 카드와 같은 자리를 잡는다 */ +export function MobileRoutePlaceCardSkeleton() { + return ( + + + + + + + + + ); +} + +/** Figma ⑤ 장소 없는 일차 — 어느 일차인지 제목을 두고, 일정 화면의 빈 일차처럼 점선 박스로 비어 있음을 보여 준다 */ +export function MobileRouteEmptyDayCard({ dayNumber }: Readonly<{ dayNumber: number }>) { + return ( +
+

Day {dayNumber}

+ +

장소가 없어요

+

지도에서 장소를 추가해 보세요.

+
+
+ ); +} + +/** 일정이 없거나 불러오지 못했을 때 — 장소카드 자리에 안내를 보여 준다 */ +export function MobileRouteMessageCard({ title, description, tone = "default" }: Readonly<{ + title: string; + description: string; + tone?: "default" | "error"; +}>) { + return ( +
+

{title}

+

{description}

+
+ ); +} diff --git a/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx new file mode 100644 index 00000000..af8e2abc --- /dev/null +++ b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { HeroImage, HeroSkeleton } from "@/components/place/HeroSection"; +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 { PlanPlace } from "@/lib/plan/types"; + +/** + * 펼친 경로 카드의 상세(Figma ④) — 사진, 홈·리뷰 탭(장소 상세 시트와 같음). + * 시간·비용·메모는 카드 윗부분에 그대로 있다. 가운데 카드(`active`)일 때만 상세를 불러온다. + * 지도에서 고른 후보 장소도 같은 상세를 쓴다(`analyticsSource="map"`). + */ +export function MobileRoutePlaceDetail({ + place, + active, + analyticsSource = "plan", +}: Readonly<{ + place: Pick; + active: boolean; + analyticsSource?: AnalyticsSource; +}>) { + const rawId = place.googlePlaceId?.trim() ?? ""; + const googlePlaceId = rawId ? normalizeGooglePlaceResourceId(rawId) : undefined; + const { data: detail, isLoading } = usePlaceDetailData(active ? googlePlaceId : undefined); + const category = detail?.primaryTypeDisplayName ?? place.primaryTypeDisplayName ?? ""; + + // 일정 상세를 본 것으로 한 번만 기록한다(장소 상세 패널과 같은 이벤트) + const trackedPlaceIdRef = useRef(null); + useEffect(() => { + if (!active || !googlePlaceId || isLoading || trackedPlaceIdRef.current === googlePlaceId) return; + trackedPlaceIdRef.current = googlePlaceId; + trackAnalyticsEvent(AnalyticsEvents.viewPlace, { place_category: category, interaction_source: analyticsSource }); + }, [active, analyticsSource, category, googlePlaceId, isLoading]); + + const showSkeleton = !active || (isLoading && !detail); + + return ( +
+
+ {showSkeleton ? ( + + ) : ( + + )} +
+ + {showSkeleton ? ( + + + + + ) : ( + // 장소 상세 시트와 같은 홈·리뷰 탭 — 같은 상세 응답을 쓰므로 추가 호출이 없다 +
+ +
+ )} + +
+ ); +} diff --git a/src/app/(main)/map/_components/useMobileRouteView.test.tsx b/src/app/(main)/map/_components/useMobileRouteView.test.tsx new file mode 100644 index 00000000..806922ff --- /dev/null +++ b/src/app/(main)/map/_components/useMobileRouteView.test.tsx @@ -0,0 +1,85 @@ +// @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"; + +const fixtures = vi.hoisted(() => ({ + schedules: [{ scheduleId: 10, dayNumber: 1, date: "2026-10-10" }], + places: [ + { 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 } }, + ], + setSelectedPlace: vi.fn(), + router: { replace: vi.fn() }, +})); + +vi.mock("next/navigation", () => ({ useRouter: () => fixtures.router })); +vi.mock("@/hooks/use-room-id", () => ({ useCurrentRoomId: () => ({ roomId: "room-1" }) })); +vi.mock("@/hooks/useRooms", () => ({ + useRoomSchedules: () => ({ data: fixtures.schedules, isSuccess: true }), + useSchedulePlanPlaces: () => ({ data: fixtures.places, isSuccess: true, isFetching: false }), +})); +vi.mock("@/hooks/usePrefetchScheduleRoutes", () => ({ usePrefetchScheduleRoutes: () => true })); +vi.mock("@/components/expenses/ExpenseProvider", () => ({ + useExpenseContext: () => ({ list: { data: [] }, canManage: true }), +})); +vi.mock("@/contexts/SelectedPlaceContext", () => ({ + useSelectedPlace: () => ({ setSelectedPlace: fixtures.setSelectedPlace }), +})); +vi.mock("@/components/place/usePlaceDetailData", () => ({ usePlaceDetailData: () => ({}) })); +vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ readSchedulePlanPlacesFromCache: () => fixtures.places })); +vi.mock("@/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch", () => ({ + useMobileAddPlaceSearch: () => ({ recentlyAddedItemId: null, screen: null }), +})); +vi.mock("@/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets", () => ({ + 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, +})); + +import { readMapRouteParams } from "@/lib/mobile-view"; +import { scheduleItemsQueryKey } from "@/lib/query-keys"; +import { useMobileRouteView } from "./useMobileRouteView"; + +it.each([ + { url: "/map?view=route&day=1", camera: "fit", item: "101" }, + { url: "/map?view=route&day=1&item=102", camera: "place", item: "102" }, +])("$url 진입 후 URL에 선택 장소가 반영되면 해당 장소를 확대한다", async ({ url, camera, item }) => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + window.history.replaceState(null, "", url); + 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); + let current: ReturnType | undefined; + function Harness() { + const route = readMapRouteParams(new URLSearchParams(window.location.search)); + const result = useMobileRouteView({ active: route.active, day: route.day, initialItemId: route.itemId }); + useEffect(() => { current = result; }); + return null; + } + const render = () => root.render(); + try { + await act(async () => render()); + expect(current?.mapRouteView?.camera.kind).toBe(camera); + expect(new URLSearchParams(window.location.search).get("item")).toBe(item); + // Next.js reflects native replaceState into useSearchParams; emulate that rerender. + await act(async () => render()); + expect(current?.mapRouteView?.camera.kind).toBe("place"); + } finally { + await act(async () => root.unmount()); + queryClient.clear(); + host.remove(); + } +}); diff --git a/src/app/(main)/map/_components/useMobileRouteView.tsx b/src/app/(main)/map/_components/useMobileRouteView.tsx new file mode 100644 index 00000000..1a0b967b --- /dev/null +++ b/src/app/(main)/map/_components/useMobileRouteView.tsx @@ -0,0 +1,557 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; + +import { useExpenseContext } from "@/components/expenses/ExpenseProvider"; +import type { MapRouteView } from "@/components/map"; +import { MapCloseButton } from "@/components/map/MapCloseButton"; +import { usePlaceDetailData } from "@/components/place/usePlaceDetailData"; +import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; +import { useCurrentRoomId } from "@/hooks/use-room-id"; +import { usePrefetchScheduleRoutes } from "@/hooks/usePrefetchScheduleRoutes"; +import { useRoomSchedules, useSchedulePlanPlaces } from "@/hooks/useRooms"; +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 { + scheduleIdsToRouteColors, + sortedScheduleIdsForRouteColors, +} from "@/lib/plan/planRouteDayColors"; +import { readSchedulePlanPlacesFromCache } from "@/lib/plan/scheduleItemPlaces"; +import { sortRoomSchedules } from "@/lib/plan/scheduleMerge"; +import type { PlanPlace } from "@/lib/plan/types"; +import { scheduleItemsQueryKey } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; + +import { + MobilePlanPlaceSheets, + openPlaceExpenses, + type MobilePlanPlaceSheet, +} from "@/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets"; +import { useMobileAddPlaceSearch } from "@/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch"; + +import { MobileRouteCandidateCard } from "./MobileRouteCandidateCard"; +import { MobileRouteCardCarousel } from "./MobileRouteCardCarousel"; +import { + MobileRouteEmptyDayCard, + MobileRouteMessageCard, + MobileRoutePlaceCard, + MobileRoutePlaceCardSkeleton, +} from "./MobileRoutePlaceCard"; +import { MobileRoutePlaceDetail } from "./MobileRoutePlaceDetail"; + +const EMPTY_PLACES: PlanPlace[] = []; +/** 일정 화면의 일차 색과 같은 기본값 */ +const FALLBACK_DAY_COLOR = "#f12d33"; + +type PlaceEntry = { + kind: "place"; + key: string; + scheduleId: number; + dayNumber: number; + orderNumber: number; + itemId: number; + place: PlanPlace; +}; +/** + * 지도에서 고른 후보 — 넣을 자리(고른 카드 바로 뒤)에 끼운다. 장소 없는 일차면 빈 카드 자리를 대신한다. + * `prev`: 후보 바로 앞 장소(장소 없는 일차면 null) — 이 장소 뒤에 넣는다. + */ +type CandidateEntry = { + kind: "candidate"; + key: string; + scheduleId: number; + dayNumber: number; + googlePlaceId: string; + prev: PlaceEntry | null; +}; +/** 모든 일차를 이어 붙인 카드 한 칸 — 장소가 없는 일차는 안내 카드 한 칸을 차지한다 */ +type StripEntry = PlaceEntry | { kind: "empty"; key: string; scheduleId: number; dayNumber: number } | CandidateEntry; + +/** 지도에서 누른 Google 장소 — `anchorKey` 카드 바로 뒤에 넣을 후보. `id`는 후보마다 새 카드 키를 만든다 */ +type RouteCandidate = { + id: number; + googlePlaceId: string; + location: google.maps.LatLngLiteral | null; + anchorKey: string; +}; + +/** + * 고른 카드 — 키로 찾고, 그 카드가 사라지면(삭제 등) 같은 자리(index)의 카드로 이어 간다. + * `key`가 null이면 아직 고르지 않은 것으로 주소(`day`·`item`)를 따른다. + */ +type Focus = { key: string | null; index: number; camera: MapRouteView["camera"] }; + +/** + * 모바일 지도의 경로 보기(`/map?view=route&day=N&item=ID`, Figma 5826:890). + * 모든 일차의 장소 카드를 한 줄로 이어 넘겨 보고(일차가 끝나면 다음 일차로), 고른 카드의 일차 경로만 지도에 그린다. + * 카드를 누르면 지도의 70%까지 펼쳐 상세를 보여 준다. 고른 카드는 주소에도 남겨 새로고침해도 이어 본다. + * 지도는 기본 지도와 같은 것을 쓰므로 지도에 넘길 `mapRouteView`와 지도 위에 얹을 `overlay`를 함께 돌려준다. + */ +export function useMobileRouteView({ + active, + day, + initialItemId, +}: { + active: boolean; + /** 주소의 일차(1부터) — 일차 수보다 크면 마지막 일차 */ + day: number; + /** 주소의 장소 — 일정 카드에서 들어올 때 처음 고를 장소 */ + initialItemId: number | null; +}): { + mapRouteView: MapRouteView | undefined; + overlay: ReactNode; + /** 기본 지도의 경로 보기 버튼 — 1일차로 연다 */ + open: () => void; +} { + const router = useRouter(); + const queryClient = useQueryClient(); + const { roomId } = useCurrentRoomId(); + const rid = typeof roomId === "string" ? roomId.trim() : ""; + const { setSelectedPlace } = useSelectedPlace(); + const expenses = useExpenseContext(); + + const schedulesQuery = useRoomSchedules(rid || null); + const schedules = useMemo(() => sortRoomSchedules(schedulesQuery.data ?? []), [schedulesQuery.data]); + + // 일정 목록을 불러올 때 모든 일차의 장소(미리보기 포함)가 캐시에 채워지므로 캐시만 읽는다 + const dayPlacesQueries = useQueries({ + queries: schedules.map((s) => ({ + queryKey: scheduleItemsQueryKey(rid || null, s.scheduleId), + queryFn: () => readSchedulePlanPlacesFromCache(queryClient, rid, s.scheduleId), + enabled: active && rid.length > 0 && schedulesQuery.isSuccess, + staleTime: Infinity, + refetchOnMount: false, + refetchOnWindowFocus: false, + })), + }); + + const [candidate, setCandidate] = useState(null); + const candidateSeqRef = useRef(0); + // 후보 카드가 불러오는 상세와 같은 조회 — 지도 핀은 누른 자리보다 정확한 장소 위치에 둔다 + const { data: candidateDetail } = usePlaceDetailData(candidate?.googlePlaceId); + + /** 후보를 끼우기 전 카드들 — 넣을 자리(`anchorKey`)는 여기서 찾는다 */ + const baseEntries: StripEntry[] = []; + const placesBySchedule = new Map(); + schedules.forEach((s, dayIndex) => { + const dayPlaces = dayPlacesQueries[dayIndex]?.data ?? EMPTY_PLACES; + placesBySchedule.set(s.scheduleId, dayPlaces); + const places = dayPlaces.filter( + (p): p is PlanPlace & { itemId: number } => typeof p.itemId === "number", + ); + if (places.length === 0) { + baseEntries.push({ kind: "empty", key: `empty-${s.scheduleId}`, scheduleId: s.scheduleId, dayNumber: dayIndex + 1 }); + return; + } + places.forEach((place, index) => { + baseEntries.push({ + kind: "place", + key: `item-${place.itemId}`, + scheduleId: s.scheduleId, + dayNumber: dayIndex + 1, + orderNumber: index + 1, + itemId: place.itemId, + place, + }); + }); + }); + + const { entries, anchorIndex, ghost } = insertCandidate(baseEntries, candidate); + + const [focus, setFocus] = useState({ key: null, index: 0, camera: { kind: "fit", seq: 0 } }); + const [expanded, setExpanded] = useState(false); + /** 카드의 ⋮·시간·메모에서 연 시트 — 일정 목록과 같은 시트를 쓴다 */ + const [routeSheet, setRouteSheet] = useState<{ scheduleId: number; sheet: MobilePlanPlaceSheet } | null>(null); + + const focusedIndex = resolveFocusedIndex(entries, focus, initialItemId, day, schedules.length); + const focused = focusedIndex >= 0 ? entries[focusedIndex] : undefined; + const activeScheduleId = active ? (focused?.scheduleId ?? null) : null; + const activeDayNumber = focused?.dayNumber ?? 0; + // 카드를 넘기는 중 가운데 카드 — 일차 탭은 이걸로 먼저 바꾸고, 지도·URL은 스냅이 멈춘 뒤 바꾼다 + const [previewIndex, setPreviewIndex] = useState(null); + const tabDayNumber = (previewIndex !== null ? entries[previewIndex]?.dayNumber : undefined) ?? activeDayNumber; + const isExpanded = active && expanded; + + // ⋮ "일정 추가"는 일정 화면과 같은 전체 화면 검색 — 추가한 장소를 바로 고른다(목록에 생기기 전엔 같은 자리를 유지) + const addPlace = useMobileAddPlaceSearch({ + roomId: rid, + backLabel: "경로로 돌아가기", + onAdded: (itemId) => focusKey(`item-${itemId}`, focusedIndex + 1), + }); + + // 고른 일차는 미리보기가 빠진 장소를 다시 채우고, 일정 화면처럼 구간 경로를 한 번에 받아 둔다 + const activePlacesQuery = useSchedulePlanPlaces(rid || null, activeScheduleId); + const activePlaces = activePlacesQuery.data ?? EMPTY_PLACES; + const routesReady = usePrefetchScheduleRoutes( + rid, + activeScheduleId ?? -1, + activePlaces, + activeScheduleId !== null && + activePlacesQuery.isSuccess && + !activePlacesQuery.isFetching && + activePlaces.length >= 2, + ); + const locations = useMemo( + () => + activePlaces.flatMap((p) => + p.location && typeof p.itemId === "number" + ? [{ itemId: p.itemId, lat: p.location.lat, lng: p.location.lng }] + : [], + ), + [activePlaces], + ); + + const colorBySchedule = useMemo( + () => + scheduleIdsToRouteColors( + sortedScheduleIdsForRouteColors(Object.fromEntries(schedules.map((s) => [s.scheduleId, true]))), + rid || undefined, + ), + [rid, schedules], + ); + const dayColor = (scheduleId: number) => colorBySchedule.get(scheduleId) ?? FALLBACK_DAY_COLOR; + + // 고른 카드를 주소에 남긴다 — 이동 기록은 쌓지 않는다(Next가 `useSearchParams`와 맞춰 준다) + const focusedItemId = focused?.kind === "place" ? focused.itemId : null; + useEffect(() => { + if (!active || activeDayNumber === 0) return; + window.history.replaceState(null, "", buildMapRouteHref(activeDayNumber, focusedItemId)); + }, [active, activeDayNumber, focusedItemId]); + + // 경로 보기에서는 장소 상세 시트를 쓰지 않는다 — 일반 지도에서 고른 장소가 남아 있으면 시트가 카드를 덮으므로 + // 경로 보기가 켜질 때(버튼·일정 카드·주소 어느 쪽으로 들어와도) 지운다 + useEffect(() => { + if (active) setSelectedPlace(null); + }, [active, setSelectedPlace]); + + function focusKey(key: string, index: number) { + setFocus((prev) => ({ key, index, camera: { kind: "pan", seq: prev.camera.seq + 1 } })); + } + + /** 일차가 바뀌면 그 일차 전체가 보이게, 같은 일차면 고른 장소로 지도를 옮긴다 */ + const focusEntry = (index: number, cameraKind?: "fit" | "pan") => { + const entry = entries[index]; + if (!entry) return; + const kind = cameraKind ?? (entry.scheduleId === focused?.scheduleId ? "pan" : "fit"); + setFocus((prev) => ({ key: entry.key, index, camera: { kind, seq: prev.camera.seq + 1 } })); + }; + const setExpandedAndRecenter = (next: boolean) => { + setExpanded(next); + // 펼치면 지도가 위쪽만 보이므로 고른 장소를 다시 그 가운데로 옮긴다 + setFocus((prev) => ({ + key: prev.key ?? focused?.key ?? null, + index: focusedIndex, + camera: { kind: "pan", seq: prev.camera.seq + 1 }, + })); + }; + + const closeCandidate = () => { + if (!candidate) return; + if (focused?.kind === "candidate") { + // 보고 있던 후보를 닫으면 넣으려던 자리(앞 카드·빈 일차)로 돌아간다 + focusKey(candidate.anchorKey, Math.max(anchorIndex, 0)); + setExpanded(false); + } + setCandidate(null); + }; + + async function addCandidate(): Promise { + if (!candidate || !ghost) return false; + const anchor = ghost.prev + ? insertAnchorAfterItem(placesBySchedule.get(ghost.scheduleId) ?? EMPTY_PLACES, ghost.prev.itemId) + : null; + const itemId = await addPlace.addAt({ scheduleId: ghost.scheduleId, anchor }, candidate.googlePlaceId, "map"); + if (itemId === null) return false; + // 새 카드는 `addPlace`의 onAdded가 골라 강조한다 — 그사이 다른 장소를 눌렀으면 그 후보는 남긴다 + const addedId = candidate.id; + setCandidate((current) => (current?.id === addedId ? null : current)); + return true; + } + + // 후보 핀은 그 일차를 볼 때만 그린다 + const ghostOnMap = ghost?.scheduleId === activeScheduleId ? ghost : undefined; + const candidateLocation = candidateDetail?.location ?? candidate?.location ?? null; + + function buildRouteView(): MapRouteView | undefined { + if (!active) return undefined; + return { + scheduleId: activeScheduleId, + color: activeScheduleId === null ? FALLBACK_DAY_COLOR : dayColor(activeScheduleId), + focusedItemId, + routesReady, + onStopClick: (itemId) => { + const index = entries.findIndex((e) => e.kind === "place" && e.itemId === itemId); + if (index >= 0) focusEntry(index, "pan"); + }, + onCandidateClick: () => { + const index = entries.findIndex((e) => e.kind === "candidate"); + if (index >= 0) focusEntry(index, "pan"); + }, + // 처음 들어올 때 — 일정 카드로 들어왔으면 그 장소를 가까이, 경로 보기 버튼이면 일차 전체를 보여 준다 + camera: focus.key === null ? { kind: initialItemId !== null ? "place" : "fit", seq: 0 } : focus.camera, + locations, + expanded: isExpanded, + candidate: + ghostOnMap && candidate + ? { + googlePlaceId: candidate.googlePlaceId, + location: candidateLocation, + focused: focused?.kind === "candidate", + } + : null, + onPlaceClick: (googlePlaceId, location) => { + // 이미 일정에 있는 장소면 후보 대신 그 카드를 고른다 + const placeId = normalizeGooglePlaceResourceId(googlePlaceId); + const index = entries.findIndex( + (e) => e.kind === "place" && normalizeGooglePlaceResourceId(e.place.googlePlaceId?.trim() ?? "") === placeId, + ); + if (index >= 0) { + setCandidate(null); + focusEntry(index); + return; + } + // 넣을 자리 — 지금 고른 카드 바로 뒤. 후보를 보고 있었으면 같은 자리에서 장소만 바꾼다 + if (!focused) return; + const anchorKey = focused.kind === "candidate" && candidate ? candidate.anchorKey : focused.key; + const nextAnchorIndex = baseEntries.findIndex((e) => e.key === anchorKey); + const nextAnchor = baseEntries[nextAnchorIndex]; + if (!nextAnchor) return; + candidateSeqRef.current += 1; + const id = candidateSeqRef.current; + setExpanded(false); + setCandidate({ id, googlePlaceId: placeId, location, anchorKey }); + // 끼운 후보 카드로 넘긴다 — 빈 일차면 빈 카드 자리, 아니면 앞 카드 바로 뒤 + focusKey(`candidate-${id}`, nextAnchor.kind === "empty" ? nextAnchorIndex : nextAnchorIndex + 1); + }, + onBackgroundClick: () => { + if (candidate) closeCandidate(); + }, + }; + } + + const goToDay = (dayNumber: number) => { + const index = entries.findIndex((e) => e.dayNumber === dayNumber); + if (index >= 0) focusEntry(index, "fit"); + }; + const close = () => { + setExpanded(false); + setCandidate(null); + setPreviewIndex(null); + setRouteSheet(null); + router.replace(MOBILE_MAP_PATH); + }; + const open = () => { + setExpanded(false); + setCandidate(null); + setPreviewIndex(null); + setFocus((prev) => ({ key: null, index: 0, camera: { kind: "fit", seq: prev.camera.seq + 1 } })); + router.replace(buildMapRouteHref(1)); + }; + + const tabsRef = useRef(null); + const selectedTabRef = useRef(null); + useEffect(() => { + const nav = tabsRef.current; + const tab = selectedTabRef.current; + if (!active || !nav || !tab) return; + // 고른 탭이 탭 줄 밖이면 탭 줄만 가로로 옮긴다 — scrollIntoView는 바깥 영역까지 스크롤해 화면이 밀릴 수 있다 + const padding = 15; + let left: number | null = null; + if (tab.offsetLeft - padding < nav.scrollLeft) left = tab.offsetLeft - padding; + else if (tab.offsetLeft + tab.offsetWidth + padding > nav.scrollLeft + nav.clientWidth) { + left = tab.offsetLeft + tab.offsetWidth + padding - nav.clientWidth; + } + if (left !== null) nav.scrollTo({ left, behavior: "smooth" }); + }, [active, tabDayNumber]); + + const itemExpenses = (scheduleId: number, itemId: number) => + expensesInScope(expenses.list.data ?? [], { scheduleId, scheduleItemId: itemId, label: "" }); + + function renderEntry(entry: StripEntry, index: number): ReactNode { + if (entry.kind === "empty") return ( + + + ); + if (entry.kind === "candidate") return ( + { + if (index !== focusedIndex) focusEntry(index); + setExpandedAndRecenter(true); + }} + onCollapse={() => setExpandedAndRecenter(false)} + detailActive={index === focusedIndex} + /> + + ); + return ( + { + if (index !== focusedIndex) focusEntry(index); + setExpandedAndRecenter(true); + }} + onCollapse={() => setExpandedAndRecenter(false)} + onOpenActions={() => setRouteSheet({ scheduleId: entry.scheduleId, sheet: { kind: "actions", place: entry.place } })} + onOpenExpenses={() => openPlaceExpenses(expenses, entry.scheduleId, entry.place)} + onEditMemo={() => setRouteSheet({ scheduleId: entry.scheduleId, sheet: { kind: "memo", place: entry.place } })} + onEditTime={() => setRouteSheet({ scheduleId: entry.scheduleId, sheet: { kind: "time", place: entry.place } })} + detail={isExpanded ? : null} + /> + + ); + } + + // 경로 보기가 아닐 때(일반 지도)는 카드를 만들지 않는다 — 장소마다 비용 요약까지 계산하므로 + function renderCards(): ReactNode { + if (!active) { + return null; + } else if (schedulesQuery.isLoading || (schedules.length > 0 && entries.length === 0)) { + return ; + } else if (schedulesQuery.isError) { + return ( + + + + ); + } else if (entries.length === 0) { + return ( + + + + ); + } else { + return ( + focusEntry(index)} + onPreviewChange={setPreviewIndex} + cards={entries.map((entry, index) => ({ + key: entry.key, + node: renderEntry(entry, index), + }))} + /> + ); + } + + } + + const sheetSchedule = routeSheet ? schedules.find((s) => s.scheduleId === routeSheet.scheduleId) : undefined; + const overlay = active ? ( + <> +
+ {/* 펼치면 X·일차 탭은 사라지고 카드가 그 자리까지 커진다 */} +
+
+ +
+ +
+ {/* 접힘: 가장 긴 카드에 맞춰 모든 카드가 같은 높이 / 펼침: 지도의 70% */} +
+ {renderCards()} +
+
+ + {routeSheet && sheetSchedule ? ( + setRouteSheet(next ? { scheduleId: routeSheet.scheduleId, sheet: next } : null)} + canInsert + onInsertFromSearch={(anchor) => addPlace.open(routeSheet.scheduleId, anchor)} + onPlaceAdded={(itemId) => { + addPlace.showAddedPlace(itemId); + focusKey(`item-${itemId}`, focusedIndex + 1); + }} + /> + ) : null} + {addPlace.screen} + + ) : null; + + return { mapRouteView: buildRouteView(), overlay, open }; +} + +/** 카드가 하나뿐일 때도 넘기는 카드와 같은 폭·위치에 둔다 */ +function RouteCardSlot({ children }: Readonly<{ children: ReactNode }>) { + return
{children}
; +} + +function insertCandidate(baseEntries: StripEntry[], candidate: RouteCandidate | null) { + const entries = [...baseEntries]; + const anchorIndex = candidate ? baseEntries.findIndex((e) => e.key === candidate.anchorKey) : -1; + const anchorEntry = anchorIndex >= 0 ? baseEntries[anchorIndex] : undefined; + let ghost: CandidateEntry | undefined; + // 넣을 자리가 사라졌으면(삭제 등) 후보도 보이지 않는다 + if (candidate && anchorEntry && anchorEntry.kind !== "candidate") { + ghost = { + kind: "candidate", + key: `candidate-${candidate.id}`, + scheduleId: anchorEntry.scheduleId, + dayNumber: anchorEntry.dayNumber, + googlePlaceId: candidate.googlePlaceId, + prev: anchorEntry.kind === "place" ? anchorEntry : null, + }; + if (anchorEntry.kind === "empty") entries.splice(anchorIndex, 1, ghost); + else entries.splice(anchorIndex + 1, 0, ghost); + } + + return { entries, anchorIndex, ghost }; +} + +function resolveFocusedIndex(entries: StripEntry[], focus: Focus, initialItemId: number | null, + day: number, scheduleCount: number) { + let focusedIndex: number; + if (focus.key === null) { + const byItem = initialItemId === null ? -1 : entries.findIndex((e) => e.kind === "place" && e.itemId === initialItemId); + focusedIndex = byItem >= 0 ? byItem : entries.findIndex((e) => e.dayNumber === Math.min(day, scheduleCount)); + } else { + const byKey = entries.findIndex((e) => e.key === focus.key); + focusedIndex = byKey >= 0 ? byKey : Math.min(focus.index, entries.length - 1); + } + + return focusedIndex; +} diff --git a/src/app/(main)/map/page.tsx b/src/app/(main)/map/page.tsx new file mode 100644 index 00000000..67082b29 --- /dev/null +++ b/src/app/(main)/map/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { MapWithDetailPanel } from "@/components/map"; +import { useMobileView } from "@/contexts/MobileViewContext"; +import { readMapRouteParams } from "@/lib/mobile-view"; + +import { useMobileRouteView } from "./_components/useMobileRouteView"; + +/** + * `/map` — 모바일 전용 지도 화면. PC는 지도가 레이아웃에 붙어 있어 일정으로 보낸다. + * `?view=route`면 같은 지도 위에 일차별 경로 보기를 얹는다(지도를 새로 만들지 않는다). + */ +export default function MapPage() { + const router = useRouter(); + const { isMobileDevice } = useMobileView(); + const route = readMapRouteParams(useSearchParams()); + const routeView = useMobileRouteView({ + active: isMobileDevice && route.active, + day: route.day, + initialItemId: route.itemId, + }); + + useEffect(() => { + if (!isMobileDevice) router.replace("/plan"); + }, [isMobileDevice, router]); + + if (!isMobileDevice) return null; + return ( + + {routeView.overlay} + + ); +} diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx index b3ba2f41..4e2088ef 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx @@ -2,37 +2,17 @@ import { useReducedMotion } from "framer-motion"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { PlanLoadingSkeleton } from "../PlanLoadingSkeleton"; -import { toast } from "sonner"; -import { - CoinIcon, - LocationAddIcon, - PlusIcon, - SavedAddIcon, - TimeClockIcon, - TrashIcon, - WriteAddIcon, - WriteIcon, -} from "@/assets/icons"; +import { PlusIcon } from "@/assets/icons"; +import { DashedPlaceholderBox } from "@/components/mobile/DashedPlaceholderBox"; import { useExpenseContext } from "@/components/expenses/ExpenseProvider"; -import { - MobileBottomSheet, - MobileSheetMenuItem, -} from "@/components/mobile/MobileBottomSheet"; -import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; -import { useOpenPlaceOnMap } from "@/hooks/useOpenPlaceOnMap"; import { usePrefetchScheduleRoutes } from "@/hooks/usePrefetchScheduleRoutes"; -import { useDeleteScheduleItem, useSchedulePlanPlaces } from "@/hooks/useRooms"; -import { bucketItemCount } from "@/lib/analytics/context"; -import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; +import { useRoomSchedules, useSchedulePlanPlaces } from "@/hooks/useRooms"; import { expensesInScope } from "@/lib/expenses/expense-scope"; -import { normalizeGooglePlaceResourceId } from "@/lib/maps"; -import { - insertAnchorAfterItem, - resolveInsertIndex, - type InsertAnchor, -} from "@/lib/plan/insertPosition"; +import { buildMapRouteHref } from "@/lib/mobile-view"; +import type { InsertAnchor } from "@/lib/plan/insertPosition"; import { summarizeExpensesForMobile } from "@/lib/plan/mobilePlanFormat"; import { planCopy } from "@/lib/plan/planCopy"; import { schedulePlacesFingerprint } from "@/lib/plan/planTravelLocalStorage"; @@ -40,25 +20,18 @@ import { scheduleIdsToRouteColors, sortedScheduleIdsForRouteColors, } from "@/lib/plan/planRouteDayColors"; +import { sortRoomSchedules } from "@/lib/plan/scheduleMerge"; import type { PlanPlace } from "@/lib/plan/types"; import { cn } from "@/lib/utils"; import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store"; -import { AddFromBookmarkModal } from "../itinerary/AddFromBookmarkModal"; import { PlanTravelTime } from "../travel-time/PlanTravelTime"; -import { MobileMemoSheet } from "./MobileMemoSheet"; import { MobilePlanPlaceCard } from "./MobilePlanPlaceCard"; -import { MobileTimeSheet } from "./MobileTimeSheet"; +import { MobilePlanPlaceSheets, openPlaceExpenses, type MobilePlanPlaceSheet } from "./MobilePlanPlaceSheets"; import { applyPendingPlanMove, useMobilePlanDrag } from "./mobilePlanDrag"; const EMPTY_PLACES: PlanPlace[] = []; -type SheetState = - | { kind: "actions"; place: PlanPlace } - | { kind: "memo"; place: PlanPlace } - | { kind: "time"; place: PlanPlace } - | { kind: "delete"; place: PlanPlace }; - type MobilePlanItineraryProps = Readonly<{ roomId: string; scheduleId: number; @@ -81,7 +54,8 @@ export function MobilePlanItinerary({ onPlaceAdded, }: MobilePlanItineraryProps) { const expenses = useExpenseContext(); - const openPlaceOnMap = useOpenPlaceOnMap(); + const router = useRouter(); + const { data: schedules } = useRoomSchedules(roomId); const { data, isLoading, isFetching, isError } = useSchedulePlanPlaces(roomId, scheduleId); const serverPlaces = data ?? EMPTY_PLACES; const drag = useMobilePlanDrag(); @@ -117,10 +91,7 @@ export function MobilePlanItinerary({ [serverPlaces], ); - const [sheet, setSheet] = useState(null); - /** anchor: 장소 사이에 넣을 기준, null이면 맨 뒤 */ - const [bookmarkTarget, setBookmarkTarget] = useState<{ anchor: InsertAnchor | null } | null>(null); - const { mutateAsync: removeItem, isPending: isRemoving } = useDeleteScheduleItem(); + const [sheet, setSheet] = useState(null); const itemExpenses = useCallback( (itemId: number | undefined) => @@ -130,45 +101,10 @@ export function MobilePlanItinerary({ [expenses.list.data, scheduleId], ); + /** 카드 → 이 일차의 경로 보기(`/map?view=route`)에서 이 장소를 고른 채로 연다 */ function openPlace(place: PlanPlace) { - const loc = place.location; - if (!loc || !Number.isFinite(loc.lat) || !Number.isFinite(loc.lng)) { - toast.info("지도에 표시할 위치 정보가 없어요."); - return; - } - const rawId = place.googlePlaceId?.trim() ?? ""; - openPlaceOnMap( - { - name: place.title, - category: "", - rating: null, - ...(rawId ? { googlePlaceId: normalizeGooglePlaceResourceId(rawId) } : {}), - location: { lat: loc.lat, lng: loc.lng }, - address: place.subtitle, - }, - { analyticsSource: "plan" }, - ); - } - - function openItemExpenses(place: PlanPlace) { - if (typeof place.itemId !== "number") return; - const scoped = itemExpenses(place.itemId); - if (scoped.length === 0) expenses.open({ scheduleId, scheduleItemId: place.itemId }); - else expenses.openScope({ scheduleId, scheduleItemId: place.itemId, label: place.title }); - } - - async function handleDelete(place: PlanPlace) { - if (typeof place.itemId !== "number") return; - try { - await removeItem({ roomId, scheduleId, itemId: place.itemId }); - trackAnalyticsEvent(AnalyticsEvents.removeFromItinerary, { - item_count_bucket: bucketItemCount(places.length - 1), - }); - toast.success("일정에서 삭제했어요."); - setSheet(null); - } catch { - toast.error("삭제하지 못했어요."); - } + const dayNumber = sortRoomSchedules(schedules ?? []).findIndex((s) => s.scheduleId === scheduleId) + 1; + router.push(buildMapRouteHref(Math.max(dayNumber, 1), place.itemId)); } // ─── 방금 추가한 카드로 스크롤 + 강조 ───────────────────────────────── @@ -186,8 +122,6 @@ export function MobilePlanItinerary({ }, [places, prefersReducedMotion, recentlyAddedItemId]); // ─── 렌더 ──────────────────────────────────────────────────────────── - const sheetPlace = sheet?.place; - const sheetItemId = typeof sheetPlace?.itemId === "number" ? sheetPlace.itemId : null; return (
@@ -220,7 +154,7 @@ export function MobilePlanItinerary({ expenseSummary={summarizeExpensesForMobile(itemExpenses(place.itemId))} onOpen={() => openPlace(place)} onOpenActions={() => setSheet({ kind: "actions", place })} - onOpenExpenses={() => openItemExpenses(place)} + onOpenExpenses={() => openPlaceExpenses(expenses, scheduleId, place)} onEditMemo={() => setSheet({ kind: "memo", place })} onEditTime={() => setSheet({ kind: "time", place })} editing={editing} @@ -253,84 +187,25 @@ export function MobilePlanItinerary({
- {sheet?.kind === "actions" && sheetPlace ? ( - setSheet(null)} - onChangeSheet={setSheet} - onInsertFromSearch={(itemId) => { - setSheet(null); - onRequestAddPlace(insertAnchorAfterItem(places, itemId)); - }} - onInsertFromBookmark={(itemId) => { - setSheet(null); - setBookmarkTarget({ anchor: insertAnchorAfterItem(places, itemId) }); - }} - onAddExpense={(itemId) => { - setSheet(null); - expenses.open({ scheduleId, scheduleItemId: itemId }); - }} - /> - ) : null} - - {sheet?.kind === "memo" && sheetPlace && sheetItemId !== null ? ( - setSheet(null)} - /> - ) : null} - - {sheet?.kind === "time" && sheetPlace && sheetItemId !== null ? ( - setSheet(null)} - /> - ) : null} - - {sheet?.kind === "delete" && sheetPlace ? ( - void handleDelete(sheetPlace)} - onCancel={() => setSheet(null)} - /> - ) : null} - - {bookmarkTarget ? ( - onPlaceAdded(item.itemId)} - onClose={() => setBookmarkTarget(null)} - /> - ) : null} + onRequestAddPlace(anchor)} + onPlaceAdded={onPlaceAdded} + /> ); } @@ -378,83 +253,6 @@ function PlaceTravelSegment({ ); } -function MobilePlanActionSheet({ - place, - itemId, - canManageExpenses, - expensesBusy, - canInsert, - onClose, - onChangeSheet, - onInsertFromSearch, - onInsertFromBookmark, - onAddExpense, -}: Readonly<{ - place: PlanPlace; - itemId: number | null; - canManageExpenses: boolean; - expensesBusy: boolean; - /** 이 장소 바로 뒤에 장소를 넣는 메뉴를 보일지 */ - canInsert: boolean; - onClose: () => void; - onChangeSheet: (sheet: SheetState) => void; - onInsertFromSearch: (itemId: number) => void; - onInsertFromBookmark: (itemId: number) => void; - onAddExpense: (itemId: number) => void; -}>) { - return ( - - {itemId === null ? ( -

- 아직 저장 중인 장소예요. 잠시 후 다시 시도해 주세요. -

- ) : ( - <> - {canInsert ? ( - <> - onInsertFromSearch(itemId)} - /> - onInsertFromBookmark(itemId)} - /> -
- - ) : null} - onChangeSheet({ kind: "memo", place })} - /> - onChangeSheet({ kind: "time", place })} - /> - {canManageExpenses ? ( - onAddExpense(itemId)} - /> - ) : null} - onChangeSheet({ kind: "delete", place })} - /> - - )} -
- ); -} - function PlanPlaceStatus({ isLoading, isError }: Readonly<{ isLoading: boolean; isError: boolean }>) { return ( <> @@ -470,31 +268,14 @@ function PlanPlaceStatus({ isLoading, isError }: Readonly<{ isLoading: boolean; ); } -/** - * 빈 일차 표시 — 평소에도 점선 박스이고, 순서 편집 중 카드가 올라오면 놓을 자리로 강조한다. - * CSS 점선은 점 길이를 못 바꿔서 SVG로 긴 점선 테두리를 그린다. - */ +/** 빈 일차 표시 — 평소에도 점선 박스이고, 순서 편집 중 카드가 올라오면 놓을 자리로 강조한다 */ function EmptyDayDropZone({ active }: Readonly<{ active: boolean }>) { return ( -
- - - {active ? "여기에 놓기" : planCopy.placesEmpty} -
+ ); } diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.test.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.test.tsx index a5b83a8d..7e2de1f4 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.test.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.test.tsx @@ -26,7 +26,7 @@ describe("MobilePlanPlaceCard", () => { ); }); - const openButton = renderer.root.findByProps({ "aria-label": "1번째 장소 카페 지도에서 보기" }); + const openButton = renderer.root.findByProps({ "aria-label": "1번째 장소 카페 경로 보기" }); expect(openButton.type).toBe("button"); act(() => openButton.props.onClick()); expect(onOpen).toHaveBeenCalledOnce(); @@ -38,4 +38,53 @@ describe("MobilePlanPlaceCard", () => { act(() => renderer.unmount()); }); + + it("빈 시간·비용·메모는 켠 경우에만 추가 줄로 보여 주고 같은 동작을 부른다", () => { + const onEditTime = vi.fn(); + const onOpenExpenses = vi.fn(); + const onEditMemo = vi.fn(); + const place = { id: "place-1", title: "카페" }; + const props = { + orderNumber: 1, + badgeColor: "#f12d33", + expenseSummary: null, + onOpenActions: vi.fn(), + onOpenExpenses, + onEditMemo, + onEditTime, + editing: false, + }; + let renderer!: ReactTestRenderer; + + act(() => { + renderer = create(); + }); + expect(renderer.root.findAllByProps({ "aria-label": "카페 방문 시간 추가" })).toHaveLength(0); + expect(renderer.root.findAllByProps({ "aria-label": "카페 비용 추가" })).toHaveLength(0); + expect(renderer.root.findAllByProps({ "aria-label": "카페 메모 추가" })).toHaveLength(0); + + act(() => { + renderer.update( + , + ); + }); + expect(renderer.root.findAllByProps({ "aria-label": "카페 메모 추가" })).toHaveLength(0); + expect(renderer.root.findAllByProps({ "aria-label": "카페 비용 추가" })).toHaveLength(0); + act(() => + renderer.root.findByProps({ "aria-label": "카페 방문 시간 추가" }).props.onClick({ stopPropagation: vi.fn() }), + ); + expect(onEditTime).toHaveBeenCalledOnce(); + + act(() => { + renderer.update( + , + ); + }); + act(() => renderer.root.findByProps({ "aria-label": "카페 비용 추가" }).props.onClick({ stopPropagation: vi.fn() })); + expect(onOpenExpenses).toHaveBeenCalledOnce(); + act(() => renderer.root.findByProps({ "aria-label": "카페 메모 추가" }).props.onClick({ stopPropagation: vi.fn() })); + expect(onEditMemo).toHaveBeenCalledOnce(); + + act(() => renderer.unmount()); + }); }); diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx index d727b5f6..ccf3e337 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx @@ -24,7 +24,10 @@ type MobilePlanPlaceCardProps = Readonly<{ highlighted?: boolean; /** `45,000 KRW 외 1건` — 없으면 비용 줄을 숨긴다 */ expenseSummary: string | null; - onOpen: () => void; + /** 카드 본문 — 없으면 본문을 눌러도 아무 일도 없다(예: 경로 보기에서 펼친 카드) */ + onOpen?: () => void; + /** 본문 버튼의 접근성 이름 끝말 — 기본 "경로 보기" */ + openLabel?: string; onOpenActions: () => void; onOpenExpenses: () => void; /** 메모 줄의 연필 아이콘 — 메모 수정 시트를 연다 */ @@ -35,13 +38,23 @@ type MobilePlanPlaceCardProps = Readonly<{ editing: boolean; dragDisabled?: boolean; onHandlePointerDown?: (e: ReactPointerEvent) => void; + /** 바깥 줄 — 예: 경로 보기에서 높이를 채운다 */ + className?: string; + /** 카드 테두리·배경 — 예: 펼친 경로 카드 안에서는 테두리를 없앤다 */ + articleClassName?: string; + /** + * 비어 있는 시간·비용·메모 자리에 흐린 "추가" 줄을 두고, 시간·비용을 늘 한 줄씩 나눠 보인다 + * — 경로 보기처럼 카드 높이를 맞춰 넘겨 볼 때. 누르면 값이 있을 때와 같은 `onEditTime`·`onOpenExpenses`·`onEditMemo`를 부른다. + * `expense`는 비용 권한이 있을 때만 켠다. + */ + addWhenEmpty?: { time: boolean; expense: boolean; memo: boolean }; }>; function stop(e: { stopPropagation: () => void }) { e.stopPropagation(); } -/** 모바일 일정 장소 카드 — 시간·비용·메모 줄은 값이 있을 때만 보인다 */ +/** 모바일 일정 장소 카드 — 시간·비용·메모 줄은 값이 있을 때만 보인다(`addWhenEmpty`면 빈 줄도 "추가"로 보인다) */ export function MobilePlanPlaceCard({ place, orderNumber, @@ -49,6 +62,7 @@ export function MobilePlanPlaceCard({ highlighted = false, expenseSummary, onOpen, + openLabel = "경로 보기", onOpenActions, onOpenExpenses, onEditMemo, @@ -56,28 +70,31 @@ export function MobilePlanPlaceCard({ editing, dragDisabled = false, onHandlePointerDown, + className, + articleClassName, + addWhenEmpty, }: MobilePlanPlaceCardProps) { - const [memoExpanded, setMemoExpanded] = useState(false); - const timeRange = formatScheduleTimeRange(place.startTime ?? "", place.endTime); - const memo = place.memo?.trim() ?? ""; const meta = [place.primaryTypeDisplayName, place.subtitle].filter(Boolean).join(" · "); return ( -
+
- +
+ + + + + {editing ? ( + + ) : null} +
+ ); +} + +function PlaceTimeAndExpense({ place, expenseSummary, addWhenEmpty, onEditTime, onOpenExpenses }: + Pick) { + const timeRange = formatScheduleTimeRange(place.startTime ?? "", place.endTime); + const addTime = !timeRange && Boolean(addWhenEmpty?.time); + const addExpense = !expenseSummary && Boolean(addWhenEmpty?.expense); + return <> + {timeRange || expenseSummary || addTime || addExpense ? ( +
{timeRange ? ( + ) : null} {expenseSummary ? ( ) : null} + {!expenseSummary && addExpense ? ( + + ) : null}
) : null} - - - + ; +} +function PlaceMemo({ place, addWhenEmpty, onEditMemo }: + Pick) { + const [memoExpanded, setMemoExpanded] = useState(false); + const memo = place.memo?.trim() ?? ""; + const addMemo = !memo && Boolean(addWhenEmpty?.memo); + return <> {memo ? (
) : null} - - - {editing ? ( - - ) : null} - - ); + {!memo && addMemo ? ( + // 메모가 있을 때와 같은 줄(구분선·아이콘·꺾쇠)을 흐리게 — 누르면 메모 시트 +
+ +
+ ) : null} + ; } diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets.tsx new file mode 100644 index 00000000..cefd0b7c --- /dev/null +++ b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { toast } from "sonner"; + +import { + CoinIcon, + LocationAddIcon, + SavedAddIcon, + TimeClockIcon, + TrashIcon, + WriteAddIcon, + WriteIcon, +} from "@/assets/icons"; +import { useExpenseContext } from "@/components/expenses/ExpenseProvider"; +import { MobileBottomSheet, MobileSheetMenuItem } from "@/components/mobile/MobileBottomSheet"; +import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; +import { useDeleteScheduleItem } from "@/hooks/useRooms"; +import { bucketItemCount } from "@/lib/analytics/context"; +import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; +import { expensesInScope } from "@/lib/expenses/expense-scope"; +import { insertAnchorAfterItem, resolveInsertIndex, type InsertAnchor } from "@/lib/plan/insertPosition"; +import type { PlanPlace } from "@/lib/plan/types"; + +import { AddFromBookmarkModal } from "../itinerary/AddFromBookmarkModal"; +import { MobileMemoSheet } from "./MobileMemoSheet"; +import { MobileTimeSheet } from "./MobileTimeSheet"; + +/** 장소 카드에서 여는 시트 — 일정 관리 메뉴와 그 안의 메모·시간·삭제, 북마크에서 추가 */ +export type MobilePlanPlaceSheet = + | { kind: "actions" | "memo" | "time" | "delete"; place: PlanPlace } + /** anchor: 장소 사이에 넣을 기준, null이면 맨 뒤 */ + | { kind: "bookmark"; anchor: InsertAnchor | null }; + +type ExpenseContextValue = ReturnType; + +/** 카드의 비용 줄 — 비용이 없으면 바로 추가, 있으면 그 장소의 비용 목록을 연다 */ +export function openPlaceExpenses(expenses: ExpenseContextValue, scheduleId: number, place: PlanPlace) { + if (typeof place.itemId !== "number") return; + const scoped = expensesInScope(expenses.list.data ?? [], { scheduleId, scheduleItemId: place.itemId, label: "" }); + if (scoped.length === 0) expenses.open({ scheduleId, scheduleItemId: place.itemId }); + else expenses.openScope({ scheduleId, scheduleItemId: place.itemId, label: place.title }); +} + +/** + * 모바일 장소 카드의 시트들 — 일정 목록과 지도 경로 보기가 함께 쓴다. + * "일정 추가"(검색)는 검색 화면이 상위에 있어 `onInsertFromSearch`로 넘긴다. + */ +export function MobilePlanPlaceSheets({ + roomId, + scheduleId, + places, + monthDayLabel, + sheet, + onChangeSheet, + canInsert, + onInsertFromSearch, + onPlaceAdded, +}: Readonly<{ + roomId: string; + scheduleId: number; + /** 이 일차의 장소(최신) — 삽입 위치를 정하고 삭제 분석 이벤트에 쓴다 */ + places: PlanPlace[]; + /** `9월 21일` — 방문 시간 시트에 표시 */ + monthDayLabel?: string; + sheet: MobilePlanPlaceSheet | null; + onChangeSheet: (sheet: MobilePlanPlaceSheet | null) => void; + /** 이 장소 바로 뒤에 장소를 넣는 메뉴를 보일지 */ + canInsert: boolean; + onInsertFromSearch: (anchor: InsertAnchor) => void; + onPlaceAdded: (itemId: number) => void; +}>) { + const expenses = useExpenseContext(); + const { mutateAsync: removeItem, isPending: isRemoving } = useDeleteScheduleItem(); + const close = () => onChangeSheet(null); + + if (!sheet) return null; + + if (sheet.kind === "bookmark") { + return ( + onPlaceAdded(item.itemId)} + onClose={close} + /> + ); + } + + const { place } = sheet; + const itemId = typeof place.itemId === "number" ? place.itemId : null; + + async function handleDelete() { + if (itemId === null) return; + try { + await removeItem({ roomId, scheduleId, itemId }); + trackAnalyticsEvent(AnalyticsEvents.removeFromItinerary, { + item_count_bucket: bucketItemCount(places.length - 1), + }); + toast.success("일정에서 삭제했어요."); + close(); + } catch { + toast.error("삭제하지 못했어요."); + } + } + + if (sheet.kind === "memo" && itemId !== null) { + return ( + + ); + } + + if (sheet.kind === "time" && itemId !== null) { + return ( + + ); + } + + if (sheet.kind === "delete") { + return ( + void handleDelete()} + onCancel={close} + /> + ); + } + + if (sheet.kind !== "actions") return null; + + return ; +} + +function PlaceActionsMenu({ place, itemId, canInsert, places, scheduleId, expenses, + close, onInsertFromSearch, onChangeSheet }: Readonly<{ + place: PlanPlace; + itemId: number | null; + canInsert: boolean; + places: PlanPlace[]; + scheduleId: number; + expenses: ExpenseContextValue; + close: () => void; + onInsertFromSearch: (anchor: InsertAnchor) => void; + onChangeSheet: (sheet: MobilePlanPlaceSheet | null) => void; +}>) { + return ( + + {itemId === null ? ( +

+ 아직 저장 중인 장소예요. 잠시 후 다시 시도해 주세요. +

+ ) : ( + <> + {canInsert ? ( + <> + { + close(); + onInsertFromSearch(insertAnchorAfterItem(places, itemId)); + }} + /> + + onChangeSheet({ kind: "bookmark", anchor: insertAnchorAfterItem(places, itemId) }) + } + /> +
+ + ) : null} + onChangeSheet({ kind: "memo", place })} + /> + onChangeSheet({ kind: "time", place })} + /> + {expenses.canManage ? ( + { + close(); + expenses.open({ scheduleId, scheduleItemId: itemId }); + }} + /> + ) : null} + onChangeSheet({ kind: "delete", place })} + /> + + )} +
+ ); +} diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanView.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanView.tsx index a364e0b6..1884e318 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanView.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanView.tsx @@ -1,23 +1,14 @@ "use client"; import { PlanLoadingSkeleton } from "../PlanLoadingSkeleton"; -import { useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef, useState, type ReactNode } from "react"; -import { flushSync } from "react-dom"; +import { useRef, useState, type ReactNode } from "react"; import { toast } from "sonner"; -import { MobilePlaceSearchScreen } from "@/components/search/MobilePlaceSearchScreen"; import type { RoomSchedule } from "@/lib/api/rooms/schedules"; -import { bucketItemCount } from "@/lib/analytics/context"; -import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; -import type { MapSearchPlaceEntry } from "@/lib/map-search-history"; -import { resolveInsertIndex, type InsertAnchor } from "@/lib/plan/insertPosition"; import { formatMobileDayDate, formatMonthDayKo } from "@/lib/plan/mobilePlanFormat"; import { planCopy } from "@/lib/plan/planCopy"; import { dayNumberForInsertAfterDayIndex } from "@/lib/plan/scheduleMerge"; -import type { PlanPlace } from "@/lib/plan/types"; -import { scheduleItemsQueryKey } from "@/lib/query-keys"; -import { useCreateRoomSchedule, useCreateScheduleItem } from "@/hooks/useRooms"; +import { useCreateRoomSchedule } from "@/hooks/useRooms"; import { cn } from "@/lib/utils"; import { useMobilePlanDayFilterStore, @@ -26,9 +17,7 @@ import { import { MobilePlanDaySection } from "./MobilePlanDaySection"; import { MobilePlanDragProvider, useMobilePlanDragController } from "./mobilePlanDrag"; - -/** 추가한 카드를 강조하는 시간 */ -const ADDED_HIGHLIGHT_MS = 1500; +import { useMobileAddPlaceSearch } from "./useMobileAddPlaceSearch"; type MobilePlanViewProps = Readonly<{ roomId: string; @@ -73,7 +62,6 @@ export function MobilePlanView({ } const { mutateAsync: createSchedule, isPending: isCreatingDay } = useCreateRoomSchedule(); - const { mutateAsync: createItem } = useCreateScheduleItem(); async function insertDayAfter(dayIndex: number) { try { @@ -89,68 +77,7 @@ export function MobilePlanView({ } // ─── 장소 추가: 지도와 같은 전체 화면 검색을 재사용 ───────────────────── - const queryClient = useQueryClient(); - const searchInputRef = useRef(null); - const [searchOpen, setSearchOpen] = useState(false); - const [searchResetKey, setSearchResetKey] = useState(0); - /** anchor: 장소 사이에 넣을 기준, null이면 맨 뒤 */ - const addTargetRef = useRef<{ scheduleId: number; anchor: InsertAnchor | null } | null>(null); - // 방금 추가한 장소 — 해당 카드로 스크롤하고 잠깐 강조한다 - const [recentlyAddedItemId, setRecentlyAddedItemId] = useState(null); - const highlightTimerRef = useRef(undefined); - useEffect(() => () => window.clearTimeout(highlightTimerRef.current), []); - - function showAddedPlace(itemId: number) { - window.clearTimeout(highlightTimerRef.current); - setRecentlyAddedItemId(itemId); - highlightTimerRef.current = window.setTimeout( - () => setRecentlyAddedItemId(null), - ADDED_HIGHLIGHT_MS, - ); - } - - function openAddPlace(scheduleId: number, anchor: InsertAnchor | null) { - addTargetRef.current = { scheduleId, anchor }; - // 검색 화면을 먼저 그린 뒤 같은 탭 안에서 focus해야 iOS에서도 키보드가 뜬다 - flushSync(() => setSearchOpen(true)); - searchInputRef.current?.focus(); - } - - function closeAddPlace() { - setSearchOpen(false); - setSearchResetKey((k) => k + 1); - } - - async function handleSelectPlace(entry: MapSearchPlaceEntry): Promise { - const target = addTargetRef.current; - if (!target) return null; - // 검색하는 동안 다른 사람이 일정을 바꿨을 수 있어 고른 시점의 최신 목록으로 위치를 정한다 - const latest = - queryClient.getQueryData( - scheduleItemsQueryKey(roomId.trim() || null, target.scheduleId), - ) ?? []; - const insertIndex = resolveInsertIndex(latest, target.anchor); - try { - const created = await createItem({ - roomId, - scheduleId: target.scheduleId, - googlePlaceId: entry.googlePlaceId, - insertIndex, - placesSnapshot: latest, - }); - trackAnalyticsEvent(AnalyticsEvents.addToItinerary, { - item_count_bucket: bucketItemCount(latest.length + 1), - interaction_source: "search", - }); - toast.success("일정에 추가했어요."); - showAddedPlace(created.itemId); - closeAddPlace(); - return entry; - } catch { - toast.error("장소를 일정에 추가하지 못했어요."); - return null; - } - } + const addPlace = useMobileAddPlaceSearch({ roomId, backLabel: "일정으로 돌아가기" }); const tabs: { key: MobilePlanDayFilter; label: string }[] = [ { key: "all", label: "전체" }, @@ -189,9 +116,9 @@ export function MobilePlanView({ onFinishEditing={finishEditing} onRequestInsertDayAfter={() => void insertDayAfter(dayIndex)} onRequestDeleteDay={() => onRequestDeleteDay(dayIndex)} - onRequestAddPlace={openAddPlace} - recentlyAddedItemId={recentlyAddedItemId} - onPlaceAdded={showAddedPlace} + onRequestAddPlace={addPlace.open} + recentlyAddedItemId={addPlace.recentlyAddedItemId} + onPlaceAdded={addPlace.showAddedPlace} /> ); }); @@ -227,14 +154,7 @@ export function MobilePlanView({ {scheduleContent} - + {addPlace.screen} ); diff --git a/src/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch.tsx b/src/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch.tsx new file mode 100644 index 00000000..edde661a --- /dev/null +++ b/src/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { MobilePlaceSearchScreen } from "@/components/search/MobilePlaceSearchScreen"; +import { useCreateScheduleItem } from "@/hooks/useRooms"; +import { bucketItemCount } from "@/lib/analytics/context"; +import { AnalyticsEvents, trackAnalyticsEvent, type AnalyticsSource } from "@/lib/analytics/track"; +import type { MapSearchPlaceEntry } from "@/lib/map-search-history"; +import { resolveInsertIndex, type InsertAnchor } from "@/lib/plan/insertPosition"; +import type { PlanPlace } from "@/lib/plan/types"; +import { scheduleItemsQueryKey } from "@/lib/query-keys"; + +const ADDED_HIGHLIGHT_MS = 1500; + +/** + * 전체 화면 검색으로 일차의 원하는 자리(anchor)에 장소를 추가한다 — 일정 화면과 지도 경로 보기가 함께 쓴다. + * 검색 화면(`screen`)은 포털로 뜨므로 어디에 렌더해도 된다. 검색 없이 고른 장소(지도)는 `addAt`으로 같은 자리에 넣는다. + */ +export function useMobileAddPlaceSearch({ + roomId, + backLabel, + onAdded, +}: { + roomId: string; + /** 검색 화면의 뒤로가기 안내 — 예: "일정으로 돌아가기" */ + backLabel: string; + /** 추가가 끝난 뒤 — 예: 경로 보기에서 새 카드를 고른다 */ + onAdded?: (itemId: number) => void; +}) { + const queryClient = useQueryClient(); + const { mutateAsync: createItem } = useCreateScheduleItem(); + const searchInputRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const [searchResetKey, setSearchResetKey] = useState(0); + /** anchor: 장소 사이에 넣을 기준, null이면 맨 뒤 */ + const addTargetRef = useRef<{ scheduleId: number; anchor: InsertAnchor | null } | null>(null); + // 방금 추가한 장소 — 해당 카드로 스크롤하고 잠깐 강조한다 + const [recentlyAddedItemId, setRecentlyAddedItemId] = useState(null); + const highlightTimerRef = useRef(undefined); + useEffect(() => () => window.clearTimeout(highlightTimerRef.current), []); + + function showAddedPlace(itemId: number) { + window.clearTimeout(highlightTimerRef.current); + setRecentlyAddedItemId(itemId); + highlightTimerRef.current = window.setTimeout(() => setRecentlyAddedItemId(null), ADDED_HIGHLIGHT_MS); + } + + /** 탭 제스처 안에서 호출해야 한다 — 검색창에 바로 포커스해 키보드를 띄운다 */ + function open(scheduleId: number, anchor: InsertAnchor | null) { + addTargetRef.current = { scheduleId, anchor }; + // 검색 화면을 먼저 그린 뒤 같은 탭 안에서 focus해야 iOS에서도 키보드가 뜬다 + flushSync(() => setSearchOpen(true)); + searchInputRef.current?.focus(); + } + + function close() { + setSearchOpen(false); + setSearchResetKey((k) => k + 1); + } + + /** anchor 자리에 장소를 넣는다 — 성공하면 새 일정 항목 ID */ + async function addAt( + target: { scheduleId: number; anchor: InsertAnchor | null }, + googlePlaceId: string, + interactionSource: AnalyticsSource, + ): Promise { + // 고르는 동안 다른 사람이 일정을 바꿨을 수 있어 고른 시점의 최신 목록으로 위치를 정한다 + const latest = + queryClient.getQueryData(scheduleItemsQueryKey(roomId.trim() || null, target.scheduleId)) ?? []; + const insertIndex = resolveInsertIndex(latest, target.anchor); + try { + const created = await createItem({ + roomId, + scheduleId: target.scheduleId, + googlePlaceId, + insertIndex, + placesSnapshot: latest, + }); + trackAnalyticsEvent(AnalyticsEvents.addToItinerary, { + item_count_bucket: bucketItemCount(latest.length + 1), + interaction_source: interactionSource, + }); + toast.success("일정에 추가했어요."); + showAddedPlace(created.itemId); + onAdded?.(created.itemId); + return created.itemId; + } catch { + toast.error("장소를 일정에 추가하지 못했어요."); + return null; + } + } + + async function handleSelectPlace(entry: MapSearchPlaceEntry): Promise { + const target = addTargetRef.current; + if (!target) return null; + const itemId = await addAt(target, entry.googlePlaceId, "search"); + if (itemId === null) return null; + close(); + return entry; + } + + const screen = ( + + ); + + return { open, addAt, screen, recentlyAddedItemId, showAddedPlace }; +} diff --git a/src/assets/icons/index.tsx b/src/assets/icons/index.tsx index 2cfac24a..5b774c6a 100644 --- a/src/assets/icons/index.tsx +++ b/src/assets/icons/index.tsx @@ -14,6 +14,7 @@ import MenuDotVerticalSvg from "@/assets/icons/menu-dot-vertical.svg"; import MenuHamburgerSvg from "@/assets/icons/menu-hamburger.svg"; import PlusSvg from "@/assets/icons/plus.svg"; import ReactionStarSvg from "@/assets/icons/reaction-star.svg"; +import RouteSvg from "@/assets/icons/route.svg"; import SavedAddSvg from "@/assets/icons/saved-add.svg"; import SavedSvg from "@/assets/icons/saved.svg"; import SearchSvg from "@/assets/icons/search.svg"; @@ -44,6 +45,8 @@ export const MenuDotVerticalIcon = createIcon(MenuDotVerticalSvg, "MenuDotVertic export const MenuHamburgerIcon = createIcon(MenuHamburgerSvg, "MenuHamburgerIcon"); export const PlusIcon = createIcon(PlusSvg, "PlusIcon"); export const ReactionStarIcon = createIcon(ReactionStarSvg, "ReactionStarIcon"); +// Figma DS에 없어 Tabler `route`(outline)를 받아 씀 — Design.md "Figma에 추가 요청할 아이콘" 참고 +export const RouteIcon = createIcon(RouteSvg, "RouteIcon"); export const SavedAddIcon = createIcon(SavedAddSvg, "SavedAddIcon"); export const SavedIcon = createIcon(SavedSvg, "SavedIcon"); export const SearchIcon = createIcon(SearchSvg, "SearchIcon"); diff --git a/src/assets/icons/route.svg b/src/assets/icons/route.svg new file mode 100644 index 00000000..3dedf5b6 --- /dev/null +++ b/src/assets/icons/route.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/chat/ChatPanel.inline.test.tsx b/src/components/chat/ChatPanel.inline.test.tsx index 394925f0..89f4f9f4 100644 --- a/src/components/chat/ChatPanel.inline.test.tsx +++ b/src/components/chat/ChatPanel.inline.test.tsx @@ -2,9 +2,8 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vitest"; -const boundary = vi.hoisted(() => ({ sendAi: vi.fn(), sendChat: vi.fn() })); -vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); -vi.mock("@/contexts/MainChromeLayoutWidthContext", () => ({ useMainChromeLayoutWidth: () => ({ chatPanelDockWidthCss: "480px", chatPanelRevealReady: true }) })); +const boundary = vi.hoisted(() => ({ sendAi: vi.fn(), sendChat: vi.fn(), pathname: "/chat", push: vi.fn(), back: vi.fn(), replace: vi.fn() })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: boundary.push, back: boundary.back, replace: boundary.replace }), usePathname: () => boundary.pathname })); vi.mock("@/hooks/useCurrentRoomTitle", () => ({ useCurrentRoomTitle: () => "제주 여행" })); vi.mock("@/hooks/use-room-id", () => ({ useCurrentRoomId: () => ({ roomId: "room" }) })); vi.mock("@/hooks/useRooms", () => ({ useRoomMembers: () => ({ data: { members: [] } }) })); @@ -31,7 +30,10 @@ it("keeps messages and the working AI composer inside one inline panel", async ( HTMLElement.prototype.scrollIntoView = vi.fn(); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); - useChatPanelStore.getState().openChat(); + // `/chat`(최대화) 주소로 직접 들어온 상태 + useChatPanelStore.setState({ minimized: false }); + // 다른 브라우저 기록이 있어도 앱에서 연 채팅이 아니므로 일정으로 돌아간다 + Object.defineProperty(window.history, "length", { configurable: true, value: 2 }); try { await act(async () => root.render()); expect(host.querySelectorAll("textarea")).toHaveLength(1); @@ -54,15 +56,23 @@ it("keeps messages and the working AI composer inside one inline panel", async ( expect(boundary.sendChat).not.toHaveBeenCalled(); expect(host.querySelectorAll("textarea")).toHaveLength(1); await act(async () => minimize!.click()); - expect(useChatPanelStore.getState().chatState).toBe("minimized"); + expect(useChatPanelStore.getState().minimized).toBe(true); + expect(boundary.replace).toHaveBeenCalledWith("/plan"); + expect(boundary.back).not.toHaveBeenCalled(); + boundary.pathname = "/plan"; await act(async () => root.render()); expect(host.querySelector('[aria-label="최대화"]')).not.toBeNull(); expect(host.querySelector('[aria-label="채팅 닫기"]')).not.toBeNull(); expect(host.textContent).not.toContain("제주 여행"); + // 다른 페이지 위의 최소화 채팅을 닫으면 페이지는 그대로 둔다 + boundary.back.mockClear(); + boundary.replace.mockClear(); await act(async () => host.querySelector('[aria-label="채팅 닫기"]')!.click()); - expect(useChatPanelStore.getState().chatState).toBe("closed"); + expect(useChatPanelStore.getState().minimized).toBe(false); + expect(boundary.back).not.toHaveBeenCalled(); + expect(boundary.replace).not.toHaveBeenCalled(); } finally { await act(async () => root.unmount()); host.remove(); - useChatPanelStore.getState().closeChat(); vi.unstubAllGlobals(); + useChatPanelStore.setState({ minimized: false }); vi.unstubAllGlobals(); } }); diff --git a/src/components/chat/ChatPanel.tsx b/src/components/chat/ChatPanel.tsx index 8ee95f27..8eb6989a 100644 --- a/src/components/chat/ChatPanel.tsx +++ b/src/components/chat/ChatPanel.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { useMainChromeLayoutWidth } from "@/contexts/MainChromeLayoutWidthContext"; import { useChat } from "@/hooks/useChat"; import { useChatMessages } from "@/hooks/useChatMessages"; import { useCurrentRoomId } from "@/hooks/use-room-id"; @@ -18,9 +17,9 @@ import { CHAT_PANEL_MINIMIZED_VIEWPORT_INSET_RIGHT_PX, } from "@/lib/chat/chat-panel-minimized-size"; import { + minimizedPanelAnimate, panelVariants, panelTransition, - getPanelAnimate, } from "./chat-animations"; export function ChatPanel({ @@ -31,8 +30,6 @@ export function ChatPanel({ inline?: boolean; }) { const { chatState, openChat, minimizeChat, closeChat } = useChat(); - const { chatPanelDockWidthCss, chatPanelRevealReady } = - useMainChromeLayoutWidth(); const isInline = mobileInline || inline; const isMinimized = !isInline && chatState === "minimized"; const panelOpen = isInline || chatState !== "closed"; @@ -147,7 +144,7 @@ export function ChatPanel({ key="chat-panel-minimized" variants={panelVariants} initial="hidden" - animate={getPanelAnimate(true)} + animate={minimizedPanelAnimate} exit="exit" transition={panelTransition} style={{ @@ -167,22 +164,6 @@ export function ChatPanel({ {panelBody} ) : null} - {chatState === "maximized" && chatPanelRevealReady ? ( - - {panelBody} - - ) : null} ); } diff --git a/src/components/chat/chat-animations.ts b/src/components/chat/chat-animations.ts index 5acf20ae..7a60c51f 100644 --- a/src/components/chat/chat-animations.ts +++ b/src/components/chat/chat-animations.ts @@ -21,17 +21,14 @@ export const panelTransition: Transition = { boxShadow: { duration: 0.25 }, }; -export function getPanelAnimate(isMinimized: boolean) { - return { - opacity: 1, - scale: 1, - y: 0, - borderRadius: isMinimized ? 16 : 0, - boxShadow: isMinimized - ? "0 20px 40px -8px rgba(0,0,0,0.18)" - : "0 0 0 0 rgba(0,0,0,0)", - }; -} +/** 다른 페이지 위에 떠 있는 최소화 채팅 — 최대화는 `/chat` 페이지가 본문으로 그린다 */ +export const minimizedPanelAnimate = { + opacity: 1, + scale: 1, + y: 0, + borderRadius: 16, + boxShadow: "0 20px 40px -8px rgba(0,0,0,0.18)", +}; export const chatTapSoft = { scale: 0.96 }; export const chatTapTransition = { diff --git a/src/components/layout/HeaderBar.tsx b/src/components/layout/HeaderBar.tsx index d2f6bc85..0ecc9e4c 100644 --- a/src/components/layout/HeaderBar.tsx +++ b/src/components/layout/HeaderBar.tsx @@ -12,7 +12,7 @@ import { useMobileView } from "@/contexts/MobileViewContext"; import { useSessionPromptVisible } from "@/hooks/useSessionPromptVisible"; import { FEEDBACK_FORM_URL } from "@/lib/contact"; import { MAIN_SIDEBAR_RAIL_WIDTH } from "@/lib/layout-tokens"; -import { buildMobilePlanPanelHref, type MobilePlanPanel } from "@/lib/mobile-view"; +import { isChatPathname, isMobileMapPathname, isPlanPathname, MOBILE_MAP_PATH } from "@/lib/mobile-view"; import { isPackingPath } from "@/lib/room-context-path"; import { RoomTripEditDialog } from "@/components/rooms/RoomTripEditDialog"; @@ -41,11 +41,9 @@ function MobileMenuIcon({ src }: { src: string }) { } const HeaderBar = ({ - mobilePlanPanel = "schedule", mobileBackHref, mobileBackLabel = "뒤로 가기", }: { - mobilePlanPanel?: MobilePlanPanel; mobileBackHref?: string; mobileBackLabel?: string; }) => { @@ -59,8 +57,8 @@ const HeaderBar = ({ if (menuState.path !== pathname) setMenuState({ path: pathname, open: false }); const menuOpen = menuState.path === pathname && menuState.open; const closeMenu = () => setMenuState({ path: pathname, open: false }); - const isPlanRoute = pathname === "/plan" || Boolean(pathname?.startsWith("/plan/")); - const showMapShortcut = isPlanRoute || pathname === "/bookmark" || pathname === "/cost" || isPackingPath(pathname ?? ""); + const isMapPanel = isMobileMapPathname(pathname ?? ""); + const showMapShortcut = isPlanPathname(pathname ?? "") || isMapPanel || isChatPathname(pathname ?? "") || pathname === "/bookmark" || pathname === "/cost" || isPackingPath(pathname ?? ""); const { dismiss: dismissFeedbackPrompt } = useSessionPromptVisible(FEEDBACK_FORM_CLICKED_KEY); const { roomId } = useCurrentRoomId(); const rid = typeof roomId === "string" ? roomId.trim() : ""; @@ -92,8 +90,7 @@ const HeaderBar = ({ (roomDetail?.id === rid ? roomDetail.title?.trim() : "") || ""; - const isMapPanel = mobilePlanPanel === "map"; - const mapHref = buildMobilePlanPanelHref(pathname || "/plan", isMapPanel ? "schedule" : "map"); + const mapHref = isMapPanel ? "/plan" : MOBILE_MAP_PATH; const mobileTitle = displayTitle ? ( <>
{displayTitle}
diff --git a/src/components/layout/LeftSection.test.tsx b/src/components/layout/LeftSection.test.tsx index a8f4023b..eaa5b447 100644 --- a/src/components/layout/LeftSection.test.tsx +++ b/src/components/layout/LeftSection.test.tsx @@ -9,7 +9,6 @@ vi.mock("@/contexts/MobileViewContext", () => ({ })); vi.mock("@/contexts/MainChromeLayoutWidthContext", () => ({ useMainChromeLayoutWidth: () => ({ - setLeftSectionRef: () => {}, leftSectionAnimateMaxWidth: state.maxWidth, leftSectionAnimateMinWidth: 0, layoutTransition: { duration: 0.01, ease: [0.4, 0, 0.2, 1] }, diff --git a/src/components/layout/LeftSection.tsx b/src/components/layout/LeftSection.tsx index 3b3b7782..91ded288 100644 --- a/src/components/layout/LeftSection.tsx +++ b/src/components/layout/LeftSection.tsx @@ -12,7 +12,6 @@ export default function LeftSection({ }) { const { isMobileDevice } = useMobileView(); const { - setLeftSectionRef, leftSectionAnimateMaxWidth, leftSectionAnimateMinWidth, layoutTransition, @@ -20,7 +19,6 @@ export default function LeftSection({ return ( { - if (previousRoute.current !== pathname) closeChat(); - previousRoute.current = pathname; - }, [pathname, closeChat]); + // 최대화 채팅은 `/chat` 페이지가 그리고, 레이아웃은 다른 페이지 위에 떠 있는 최소화 채팅만 맡는다 + const { chatState } = useChat(); const showDesktopChat = !isMobileDevice && chatState === "maximized"; const isPackingRoute = isPackingPath(pathname); @@ -56,39 +57,34 @@ export function MainLayoutChrome({ children }: { children: ReactNode }) { } // Full-width tabs hide an existing map; direct entry does not create one. const keepDesktopMap = !isMobileDevice && roomId && (showDesktopMap || mapMountedRoom === roomId); - const isPlanRoute = pathname === "/plan" || pathname.startsWith("/plan/"); - const mobilePlanPanel = - isMobileDevice && isPlanRoute - ? readMobilePlanPanel(searchParams.get("view")) - : "schedule"; - const showMobilePlanSurface = - isMobileDevice && isPlanRoute && mobilePlanPanel !== "schedule"; - // 모바일 일정은 탭 고정 + 목록만 스크롤하도록 화면이 직접 스크롤을 관리한다 - const showMobileSchedule = - isMobileDevice && isPlanRoute && mobilePlanPanel === "schedule"; + const isPlanRoute = isPlanPathname(pathname); + // 모바일 지도·채팅(`/map`, `/chat`)과 일정은 화면이 직접 스크롤을 관리한다(탭 고정 + 목록만 스크롤) + const showMobileSurface = + isMobileDevice && (isMobileMapPathname(pathname) || isChatPathname(pathname)); + const showMobileSchedule = isMobileDevice && isPlanRoute; // 뒤로가기 헤더(Figma Default / Type=Back): 북마크 상세 → 목록, 지도 → 일정 - const mobileBack = getMobileBackLink(pathname, isMobileDevice, showMobilePlanSurface, mobilePlanPanel); + const mobileBack = getMobileBackLink(pathname, isMobileDevice); + + // 예전 `/plan?view=map|chat` 주소로 들어오면 새 경로로 옮긴다(방은 `/plan/[roomId]`가 먼저 저장해 둔다) + const legacyViewPath = isMobileDevice && isPlanRoute ? legacyPlanViewPath(searchParams.get("view")) : null; + useEffect(() => { + if (legacyViewPath) router.replace(legacyViewPath); + }, [legacyViewPath, router]); - let mainContent: ReactNode = children; - if (showDesktopChat) mainContent = ; - else if (showMobilePlanSurface) { - mainContent = mobilePlanPanel === "map" ? : ; - } return (
{isMobileDevice && (pathname === "/cost" || isPackingRoute) ? : null}
{!isMobileDevice ? : null} - - {mainContent} + + {legacyViewPath ? null : children}
diff --git a/src/components/layout/SideBar.tsx b/src/components/layout/SideBar.tsx index c899435d..2bb66474 100644 --- a/src/components/layout/SideBar.tsx +++ b/src/components/layout/SideBar.tsx @@ -35,7 +35,7 @@ function isSidebarItemActive(pathname: string, key: string, href: string) { function SideBar() { const pathname = usePathname(); - const { chatState, openChat, closeChat } = useChat(); + const { chatState, openChat } = useChat(); const pendingJoinRequestsCount = useHostJoinRequestsBadgeCount(); const { roomId } = useCurrentRoomId(); @@ -62,10 +62,6 @@ function SideBar() { label={item.label} tutorialTarget={item.key} isActive={!isChatActive && isSidebarItemActive(pathname, item.key, href)} - onClick={() => { - // Keep chat covering the previous page until a different route commits. - if (pathname === href || (item.key === "plan" && isSidebarItemActive(pathname, item.key, href))) closeChat(); - }} showDividerBelow={item.key === "packing"} /> ) : null; @@ -88,7 +84,6 @@ function SideBar() { label="멤버" tutorialTarget="member-settings" isActive={!isChatActive && isOnMemberSettings} - onClick={() => { if (pathname === "/member-settings") closeChat(); }} showPingBadge={showSettingsNotification} /> diff --git a/src/components/layout/mobile-header.test.tsx b/src/components/layout/mobile-header.test.tsx index aea28cdd..1ce395c6 100644 --- a/src/components/layout/mobile-header.test.tsx +++ b/src/components/layout/mobile-header.test.tsx @@ -33,7 +33,7 @@ async function rerender() { await act(async () => root.render()); } it("shows the room details and map action in the mobile top navigation", () => { expect(host.querySelector("header")?.textContent).toContain("가을 여행"); expect(host.querySelector("header")?.textContent).toContain("26/10/10 - 26/10/12"); - expect(host.querySelector('a[aria-label="지도 보기"]')?.getAttribute("href")).toBe("/plan/room?view=map"); + expect(host.querySelector('a[aria-label="지도 보기"]')?.getAttribute("href")).toBe("/map"); expect(host.querySelector('a[aria-label="홈으로 이동"]')?.getAttribute("href")).toBe("/home"); }); @@ -63,10 +63,23 @@ it("opens the mobile menu with working destinations and closes it with Escape", it.each(["/bookmark", "/cost", "/packing/12345678-1234-1234-1234-123456789abc"])("shows the map shortcut on %s", async pathname => { state.pathname = pathname; await rerender(); - expect(host.querySelector('a[aria-label="지도 보기"]')?.getAttribute("href")).toBe("/plan?view=map"); + expect(host.querySelector('a[aria-label="지도 보기"]')?.getAttribute("href")).toBe("/map"); expect(host.querySelector('button[aria-label="메뉴 열기"]')).not.toBeNull(); }); +it("keeps the map shortcut on the mobile chat screen", async () => { + state.pathname = "/chat"; + await rerender(); + expect(host.querySelector('a[aria-label="지도 보기"]')?.getAttribute("href")).toBe("/map"); +}); + +it("switches the shortcut to the schedule on the map screen", async () => { + state.pathname = "/map"; + await rerender(); + expect(host.querySelector('a[aria-label="일정 보기"]')?.getAttribute("href")).toBe("/plan"); + expect(host.querySelector('a[aria-label="지도 보기"]')).toBeNull(); +}); + it("keeps the map shortcut hidden on search", async () => { state.pathname = "/search"; await rerender(); diff --git a/src/components/layout/travel-navigation.test.tsx b/src/components/layout/travel-navigation.test.tsx index 032fee35..c410c46f 100644 --- a/src/components/layout/travel-navigation.test.tsx +++ b/src/components/layout/travel-navigation.test.tsx @@ -3,8 +3,23 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; -const state = vi.hoisted(() => ({ pathname: "/plan/room", query: "", mobile: false, unread: 0, roomId: "12345678-1234-1234-1234-123456789abc" })); -vi.mock("next/navigation", () => ({ usePathname: () => state.pathname, useSearchParams: () => new URLSearchParams(state.query) })); +const state = vi.hoisted(() => ({ pathname: "/plan/room", query: "", mobile: false, unread: 0, roomId: "12345678-1234-1234-1234-123456789abc", replace: (() => {}) as (href: string) => void })); +// 실제 이동처럼 push·back이 경로와 기록을 바꾼다 — 다음 render()에서 반영된다 +const history = vi.hoisted(() => [] as string[]); +const entryStates = vi.hoisted(() => [] as unknown[]); +const push = vi.hoisted(() => (href: string) => { + history.push(state.pathname); + entryStates.push(window.history.state); + window.history.replaceState(null, ""); + state.pathname = href.split("?")[0]; +}); +const back = vi.hoisted(() => () => { + state.pathname = history.pop() ?? state.pathname; + window.history.replaceState(entryStates.pop() ?? null, ""); +}); +// 브라우저 기록 개수도 가짜 기록에 맞춘다 — 처음 들어온 페이지 1개 + push한 개수 +Object.defineProperty(window.history, "length", { configurable: true, get: () => history.length + 1 }); +vi.mock("next/navigation", () => ({ usePathname: () => state.pathname, useSearchParams: () => new URLSearchParams(state.query), useRouter: () => ({ replace: state.replace, push, back }) })); vi.mock("next/link", () => ({ default: ({ href, children, ...props }: React.ComponentProps<"a">) => {children} })); vi.mock("@/contexts/MobileViewContext", () => ({ useMobileView: () => ({ isMobileDevice: state.mobile }) })); vi.mock("@/hooks/useHostJoinRequestsBadgeCount", () => ({ useHostJoinRequestsBadgeCount: () => 2 })); @@ -18,19 +33,31 @@ vi.mock("@/components/map", () => ({ MapWithDetailPanel: ({ hidden }: { hidden?: vi.mock("@/components/chat", () => ({ ChatPanel: ({ inline }: { inline?: boolean }) =>
})); import { MainLayoutChrome } from "./MainLayoutChrome"; import { useChatPanelStore } from "@/stores/chat-panel-store"; +import { useChat } from "@/hooks/useChat"; + +/** 채팅 패널 헤더의 최소화·닫기 버튼 대신 — 실제 `useChat` 동작을 누른다 */ +function ChatControls() { + const { minimizeChat, closeChat } = useChat(); + return
; +} let host: HTMLDivElement; let root: Root; beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - Object.assign(state, { pathname: "/plan/room", query: "", mobile: false, unread: 0, roomId: "12345678-1234-1234-1234-123456789abc" }); - useChatPanelStore.getState().closeChat(); + Object.assign(state, { pathname: "/plan/room", query: "", mobile: false, unread: 0, roomId: "12345678-1234-1234-1234-123456789abc", replace: vi.fn() }); + useChatPanelStore.setState({ minimized: false }); + history.length = 0; + entryStates.length = 0; + window.history.replaceState(null, ""); HTMLElement.prototype.scrollTo = vi.fn(); host = document.createElement("div"); document.body.append(host); root = createRoot(host); host.addEventListener("click", event => event.preventDefault()); }); afterEach(async () => { await act(async () => root.unmount()); host.remove(); }); -async function render() { await act(async () => root.render(
일정 본문
)); } +async function render() { await act(async () => root.render(<>
일정 본문
)); } +async function control(name: "minimize" | "close") { await act(async () => host.querySelector(`[data-control="${name}"]`)!.click()); await render(); } +async function openChatFromSidebar() { await act(async () => items()[5].click()); await render(); } function nav() { return host.querySelector('nav[aria-label="여행 주요 메뉴"],nav[aria-label="모바일 주요 메뉴"]')!; } function items() { return [...nav().querySelectorAll("a,button")]; } function active() { return [...nav().querySelectorAll('[aria-current="page"], [aria-pressed="true"]')]; } @@ -59,36 +86,76 @@ it("switches between expenses and packing inside the mobile travel tools tab", a expect(nav().querySelector('[aria-current="page"]')?.getAttribute("aria-label")).toBe("여행 도구"); }); -it("desktop selects chat alone in the content panel and route links restore route content", async () => { +it("desktop chat menu opens the /chat route next to the map, and route links leave it", async () => { await render(); expect(items().map(x => x.getAttribute("aria-label") ?? x.textContent)).toEqual(["일정", "검색", "북마크", "가계부", "준비물", "채팅", "멤버"]); expect(active()).toHaveLength(1); - await act(async () => items()[5].click()); + await openChatFromSidebar(); + expect(state.pathname).toBe("/chat"); expect(active()).toEqual([items()[5]]); - expect(host.querySelector("article")).toBeNull(); - expect(host.querySelectorAll("[data-chat]")).toHaveLength(1); - expect(host.querySelector("[data-main-content-scroll] [data-chat]")).not.toBeNull(); - expect(host.querySelectorAll("[data-map]")).toHaveLength(1); - await act(async () => items()[0].dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))); - expect(host.querySelector("article")).not.toBeNull(); + // 최대화 채팅은 `/chat` 페이지(본문)가 그린다 — 레이아웃은 지도만 옆에 둔다 + expect(host.querySelector("[data-chat]")).toBeNull(); + expect(host.querySelectorAll("[data-map]:not([hidden])")).toHaveLength(1); + state.pathname = "/plan/room"; await render(); + expect(active()).toEqual([items()[0]]); expect(host.querySelector("[data-chat]")).toBeNull(); }); -it("minimizing desktop chat restores the route content and docks chat over the map", async () => { +it("minimizing desktop chat returns to the previous route and docks chat over the map", async () => { await render(); - await act(async () => useChatPanelStore.getState().openChat()); - expect(host.querySelector('[data-main-content-scroll] [data-chat][data-inline="true"]')).not.toBeNull(); - await act(async () => useChatPanelStore.getState().minimizeChat()); + await openChatFromSidebar(); + await control("minimize"); + expect(state.pathname).toBe("/plan/room"); expect(host.querySelector("article")).not.toBeNull(); expect(host.querySelector('[data-chat][data-inline="false"]')).not.toBeNull(); expect(host.querySelector("[data-map]")).not.toBeNull(); }); -it("external route changes close chat too", async () => { - await render(); await act(async () => useChatPanelStore.getState().openChat()); +it("minimizing goes back in history instead of stacking entries; direct /chat entry replaces to /plan", async () => { + state.pathname = "/bookmark"; await render(); + await openChatFromSidebar(); + await control("minimize"); + expect(state.pathname).toBe("/bookmark"); + expect(history).toEqual([]); + // 새 탭에서 주소로 바로 들어온 `/chat` — 기록이 하나뿐이라 /plan으로 바꾼다 + state.pathname = "/chat"; await render(); + await control("close"); + expect(state.replace).toHaveBeenLastCalledWith("/plan"); +}); +it("leaving /chat through a menu closes chat without a minimized panel", async () => { + await render(); + await openChatFromSidebar(); state.pathname = "/bookmark"; await render(); - expect(useChatPanelStore.getState().chatState).toBe("closed"); expect(host.querySelector("[data-chat]")).toBeNull(); expect(active()).toEqual([items()[2]]); }); +it.each(["minimize", "close"] as const)("%s uses /plan for direct chat entry even with unrelated browser history", async action => { + history.push("https://external.example"); + state.pathname = "/chat"; + await render(); + await control(action); + expect(state.replace).toHaveBeenLastCalledWith("/plan"); + expect(history).toEqual(["https://external.example"]); + expect(useChatPanelStore.getState().minimized).toBe(action === "minimize"); +}); +it("minimized chat follows route changes and same-menu clicks", async () => { + await render(); + await openChatFromSidebar(); + await control("minimize"); + state.pathname = "/bookmark"; await render(); + expect(host.querySelector('[data-chat][data-inline="false"]')).not.toBeNull(); + await act(async () => nav().querySelector('a[href="/bookmark"]')!.click()); + await render(); + expect(useChatPanelStore.getState().minimized).toBe(true); + expect(host.querySelector('[data-chat][data-inline="false"]')).not.toBeNull(); +}); +it("closing the chat on /chat returns to the previous route; reopening comes back to /chat", async () => { + state.pathname = "/bookmark"; await render(); + await openChatFromSidebar(); + await control("close"); + expect(state.pathname).toBe("/bookmark"); + expect(host.querySelector("[data-chat]")).toBeNull(); + await openChatFromSidebar(); + expect(state.pathname).toBe("/chat"); +}); it.each([[4, "4"], [120, "99+"]])("retains unread value %i and the separate feedback survey", async (count, label) => { state.unread = count; await render(); expect(items()[5].textContent).toContain(label); @@ -108,21 +175,36 @@ it("shows labeled feedback and bug report links in the sidebar", async () => { it("mobile uses the four Figma destinations, with travel tools opening expenses", async () => { state.mobile = true; await render(); expect(items().map(x => x.getAttribute("aria-label") ?? x.textContent)).toEqual(["일정", "북마크", "여행 도구", "채팅"]); - expect(items().map(x => x.getAttribute("href"))).toEqual(["/plan/room", "/bookmark", "/cost", "/plan/room?view=chat"]); + expect(items().map(x => x.getAttribute("href"))).toEqual(["/plan/room", "/bookmark", "/cost", "/chat"]); expect(nav().className).toContain("safe-area-inset-bottom"); expect(nav().querySelector('a[href="/search"]')).toBeNull(); expect(items().every(item => item.querySelector('[style*="mask"]'))).toBe(true); expect(host.querySelector("article")!.compareDocumentPosition(nav()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - state.query = "view=map"; await render(); + // 지도·채팅은 각자의 페이지가 본문을 그리고, 레이아웃은 탭만 고른다 + state.pathname = "/map"; await render(); expect(active()).toEqual([items()[0]]); - expect(host.querySelector("article")).toBeNull(); - expect(host.querySelectorAll("[data-map]")).toHaveLength(1); - state.query = "view=chat"; await render(); + expect(items()[0].getAttribute("href")).toBe("/plan"); + expect(host.querySelector("article")).not.toBeNull(); + state.pathname = "/chat"; await render(); expect(active()).toEqual([items()[3]]); - expect(host.querySelectorAll("[data-chat]")).toHaveLength(1); + expect(host.querySelector("[data-chat]")).toBeNull(); expect(host.querySelector("[data-map]:not([hidden])")).toBeNull(); }); +it("moves old mobile ?view=map|chat links to /map and /chat", async () => { + state.mobile = true; + state.query = "view=map"; await render(); + expect(state.replace).toHaveBeenLastCalledWith("/map"); + state.query = "view=chat"; await render(); + expect(state.replace).toHaveBeenLastCalledWith("/chat"); + // 옮기는 동안에는 일정 본문을 그리지 않는다(깜빡임 방지) + expect(host.querySelector("article")).toBeNull(); + vi.mocked(state.replace).mockClear(); + state.mobile = false; await render(); + expect(state.replace).not.toHaveBeenCalled(); + expect(host.querySelector("article")).not.toBeNull(); +}); + it("shows unread messages on the mobile chat tab", async () => { state.mobile = true; state.unread = 4; @@ -155,18 +237,17 @@ it("uses the back header only within a bookmark folder and on the map", async () state.pathname = "/bookmark"; await render(); expect(host.querySelector("header")?.hasAttribute("data-mobile-back-href")).toBe(false); - state.pathname = "/plan/room"; - state.query = "view=map"; + state.pathname = "/map"; await render(); - expect(host.querySelector("header")?.getAttribute("data-mobile-back-href")).toBe("/plan/room"); - state.query = "view=chat"; + expect(host.querySelector("header")?.getAttribute("data-mobile-back-href")).toBe("/plan"); + state.pathname = "/chat"; await render(); expect(host.querySelector("header")?.hasAttribute("data-mobile-back-href")).toBe(false); }); it("cost follows bookmarks, selects alone and closes chat", async () => { await render(); - await act(async () => useChatPanelStore.getState().openChat()); + await openChatFromSidebar(); const cost = nav().querySelector('a[href="/cost"]'); expect(cost).not.toBeNull(); await act(async () => cost!.click()); @@ -201,16 +282,17 @@ it.each([false, true])("packing selects its desktop item or mobile travel tools expect(host.querySelector("[data-main-content-scroll]")?.className).toContain("overflow-hidden"); expect(host.querySelector("[data-main-content-scroll]")?.className).not.toContain("overflow-y-auto"); if (!mobile) { - await act(async () => items()[5].click()); + await openChatFromSidebar(); expect(active()).toEqual([items()[5]]); - expect(host.querySelector("[data-chat]")).not.toBeNull(); - expect(host.querySelector("article")).toBeNull(); - expect(host.querySelector("[data-map]")).not.toBeNull(); - await act(async () => useChatPanelStore.getState().minimizeChat()); + // 준비물은 지도 없이 전체 폭이지만, 채팅(`/chat`)은 지도 옆에 연다 + expect(host.querySelector("[data-map]:not([hidden])")).not.toBeNull(); + await control("minimize"); + expect(state.pathname).toBe(`/packing/${state.roomId}`); expect(host.querySelector("article")).not.toBeNull(); expect(host.querySelector('[data-chat][data-inline="false"]')).not.toBeNull(); expect(host.querySelector("[data-map]:not([hidden])")).toBeNull(); - await act(async () => useChatPanelStore.getState().closeChat()); + await control("close"); + expect(host.querySelector("[data-chat]")).toBeNull(); expect(host.querySelector("article")).not.toBeNull(); expect(host.querySelector("[data-map]:not([hidden])")).toBeNull(); expect(active().map(item => item.getAttribute("aria-label"))).toEqual(["준비물"]); @@ -220,26 +302,26 @@ it.each([false, true])("packing selects its desktop item or mobile travel tools } }); -it('keeps chat visible until another route commits, without revealing the old packing page', async () => { +it('keeps /chat until another route commits, then shows that route', async () => { state.pathname=`/packing/${state.roomId}`; await render(); - await act(async()=>items()[5].click()); + await openChatFromSidebar(); await act(async()=>items()[6].click()); - expect(host.querySelector('article')).toBeNull(); - expect(host.querySelector('[data-chat]')).not.toBeNull(); + expect(state.pathname).toBe('/chat'); + expect(active().map(item=>item.getAttribute('aria-label'))).toEqual(['채팅']); state.pathname='/member-settings'; await render(); expect(host.querySelector('[data-chat]')).toBeNull(); - expect(host.querySelector('article')).not.toBeNull(); expect(active().map(item=>item.getAttribute('aria-label'))).toEqual(['멤버']); }); -it('immediately restores packing when selecting the original tab from chat', async () => { +it('links back to the original packing tab from /chat', async () => { state.pathname=`/packing/${state.roomId}`; await render(); - await act(async()=>items()[5].click()); - await act(async()=>items()[4].click()); + await openChatFromSidebar(); + expect(items()[4].getAttribute('href')).toBe(`/packing/${state.roomId}`); + state.pathname=`/packing/${state.roomId}`; await render(); expect(host.querySelector('[data-chat]')).toBeNull(); - expect(host.querySelector('article')).not.toBeNull(); + expect(active().map(item=>item.getAttribute('aria-label'))).toEqual(['준비물']); }); it('creates the desktop map lazily and preserves the same map through full-width tabs', async () => { @@ -268,11 +350,17 @@ it.each([ ])('selects the visible route after minimizing chat on %s', async (pathname, label) => { state.pathname = pathname; await render(); - await act(async () => useChatPanelStore.getState().openChat()); + await openChatFromSidebar(); expect(active().map(item => item.getAttribute('aria-label'))).toEqual(['채팅']); - await act(async () => useChatPanelStore.getState().minimizeChat()); + await control('minimize'); + expect(state.pathname).toBe(pathname); expect(active().map(item => item.getAttribute('aria-label'))).toEqual([label]); expect(host.querySelector('[data-chat][data-inline="false"]')).not.toBeNull(); - await act(async () => items()[5].click()); + // 최소화 중에 채팅에 들어가면 최대화(`/chat`)되고, 메뉴로 나가도 최소화 창이 다시 뜨지 않는다 + await openChatFromSidebar(); + expect(state.pathname).toBe('/chat'); expect(active().map(item => item.getAttribute('aria-label'))).toEqual(['채팅']); + expect(host.querySelector('[data-chat]')).toBeNull(); + state.pathname = pathname; await render(); + expect(host.querySelector('[data-chat]')).toBeNull(); }); diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index 1affbf5e..6d224724 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -30,6 +30,7 @@ import { writeRoomMapViewport, } from "@/lib/map-room-viewport-storage"; import { isPlanPath } from "@/lib/layout/mainChromeLayoutWidth"; +import { isMobileMapPathname } from "@/lib/mobile-view"; import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius"; import { useMapCenterStore } from "@/stores/map-center-store"; import { useSearchMapPinsStore } from "@/stores/search-map-pins-store"; @@ -38,6 +39,7 @@ import { useMapPinsFocusStore } from "@/stores/map-pins-focus-store"; import { usePlanItineraryStopNormalizedPlaceIds } from "@/hooks/usePlanItineraryStopNormalizedPlaceIds"; import { useRoomDetail } from "@/hooks/useRoomDetail"; import { useRoomsList } from "@/hooks/useRooms"; +import { RouteIcon } from "@/assets/icons"; import { MapPinIcon } from "@/components/icons"; import { MapPinWithPlaceName } from "@/components/map/MapPinWithPlaceName"; import { MapBookmarkPins } from "./MapBookmarkPins"; @@ -47,10 +49,38 @@ import { } from "./map-pin-stroke"; import { MapSearchHereButton } from "./MapSearchHereButton"; import { MapDiscoverToolbar } from "./MapDiscoverToolbar"; +import { RouteViewCameraController } from "./RouteViewCameraController"; import { MapDiscoverPlaces } from "./MapDiscoverPlaces"; -import { PlanItineraryMapRoutes } from "./PlanItineraryMapRoutes"; +import { PlanItineraryMapRoutes, type PlanItineraryRouteDay } from "./PlanItineraryMapRoutes"; import type { OpenValue, RatingValue } from "./map-filters"; +/** 모바일 경로 보기 — 고른 일차만 그리고, 카드 선택에 맞춰 지도를 옮긴다 */ +export type MapRouteView = PlanItineraryRouteDay & { + /** + * `seq`가 바뀔 때마다 실행 — fit: 일차 전체가 보이게, pan: 줌은 두고 고른 장소로 이동, + * place: 고른 장소를 가까이(줌 15) 보여 준다(일정 카드를 눌러 들어올 때) + */ + camera: { kind: "fit" | "pan" | "place"; seq: number }; + /** 일차 장소 위치(순서대로) — 호출측에서 memo해 넘긴다 */ + locations: ReadonlyArray<{ itemId: number; lat: number; lng: number }>; + /** 카드를 펼쳐 지도 아래쪽 대부분을 가리는 중 */ + expanded: boolean; + /** + * 지도에서 누른 Google 장소 — 카드 줄 사이에 끼운 후보(이 일차를 볼 때만). 일반 지도의 선택 핀으로 그린다. + * `focused`: 후보 카드를 보고 있음 — 이때만 지도를 후보로 옮긴다. + */ + candidate: { + googlePlaceId: string; + location: google.maps.LatLngLiteral | null; + focused: boolean; + } | null; + onPlaceClick: (googlePlaceId: string, location: google.maps.LatLngLiteral | null) => void; + /** 후보 핀을 누름 — 일정 핀처럼 그 후보 카드로 넘긴다 */ + onCandidateClick: () => void; + /** 장소가 아닌 곳을 누름 — 후보를 닫는다 */ + onBackgroundClick: () => void; +}; + // ─── 장소 선택 시 지도 이동 ─────────────────────────────────────────────────── function SelectedPlaceController() { @@ -269,12 +299,21 @@ function MapSearchResultPins() { // ─── Component ─────────────────────────────────────────────────────────────── -export default function Map() { +export default function TripMap({ + routeView, + onOpenRouteView, +}: { + /** 있으면 경로 보기 — 검색·필터, 북마크·탐색 핀, 다른 일차 경로를 숨긴다 */ + routeView?: MapRouteView; + /** 있으면 지도 왼쪽 아래에 경로 보기 버튼을 둔다(모바일 지도) */ + onOpenRouteView?: () => void; +} = {}) { const pathname = usePathname(); - const isPlanPage = isPlanPath(pathname ?? ""); + // 모바일 지도(`/map`)도 일정 화면의 지도라 일정에 있는 장소의 북마크 핀을 숨긴다 + const isPlanPage = isPlanPath(pathname ?? "") || isMobileMapPathname(pathname ?? ""); const planStopPlaceIds = usePlanItineraryStopNormalizedPlaceIds(isPlanPage); - const { selectedPlace, setSelectedPlace } = useSelectedPlace(); + const { setSelectedPlace } = useSelectedPlace(); const setMapCamera = useMapCenterStore((s) => s.setMapCamera); const [selectedCategoryId, setSelectedCategoryId] = useState( null, @@ -287,16 +326,7 @@ export default function Map() { const { data: roomsData, isFetched: roomsFetched } = useRoomsList(); const { data: roomDetail, isFetched: roomDetailFetched } = useRoomDetail(currentRoomId); - const listDestination = - roomsData?.rooms.find((r) => r.id === currentRoomId)?.destinations?.[0] ?? - null; - const detailDestination = - roomDetail?.id === currentRoomId - ? (roomDetail.destinations?.[0] ?? null) - : null; - const destination = listDestination ?? detailDestination; - const mapMetaReady = - roomsFetched || (roomDetailFetched && Boolean(destination?.trim())); + const { destination, mapMetaReady } = resolveMapDestination(currentRoomId, roomsData, roomsFetched, roomDetail, roomDetailFetched); const geocodingLib = useMapsLibrary("geocoding"); const bootstrap = useTripMapBootstrap( @@ -380,6 +410,16 @@ export default function Map() { const handleMapClick = (ev: MapMouseEvent) => { const placeId = ev.detail.placeId?.trim() ?? ""; + if (routeView) { + // 경로 보기: 장소 상세 시트 대신 고른 카드 뒤에 넣을 후보로 보여 준다 + if (placeId.length > 0) { + ev.stop(); + routeView.onPlaceClick(placeId, ev.detail.latLng ?? null); + } else { + routeView.onBackgroundClick(); + } + return; + } if (placeId.length > 0) { ev.stop(); const latLng = ev.detail.latLng; @@ -422,7 +462,7 @@ export default function Map() { streetViewControl={false} mapTypeControl={false} fullscreenControl={false} - clickableIcons={selectedCategoryId == null} + clickableIcons={Boolean(routeView) || selectedCategoryId == null} onClick={handleMapClick} onCameraChanged={handleCameraChanged} > @@ -439,68 +479,46 @@ export default function Map() { destination={destination} /> - - - - - - - - - {selectedPlace?.location && ( - e.stop()} - > - - - - + {routeView ? : ( + <> + + + + + )} + + {!routeView && } )}
- + {routeView ? null : ( + + )} - {!bootstrap.ready ? null : ( + {!bootstrap.ready || routeView ? null : ( <> -
+
+ {onOpenRouteView ? ( + + ) : null}
); } + +function RouteViewMarkers({ routeView }: Readonly<{ routeView: MapRouteView }>) { + return ( + <> + + + {routeView.candidate?.location ? ( + { + e.stop(); + routeView.onCandidateClick(); + }} + > + + + + + ) : null} + + ); +} + +function SelectedPlaceMarker() { + const { selectedPlace } = useSelectedPlace(); + const bookmarkColor = selectedPlace?.fromBookmark ? selectedPlace.bookmarkCategoryColor?.trim() : undefined; + let colorClass = "text-primary"; + if (selectedPlace?.fromBookmark) colorClass = bookmarkColor ? "" : "text-secondary"; + return <> + {selectedPlace?.location && ( + e.stop()} + > + + + + + )} + ; +} + +function resolveMapDestination( + currentRoomId: string | null, + roomsData: ReturnType["data"], + roomsFetched: boolean, + roomDetail: ReturnType["data"], + roomDetailFetched: boolean, +) { + const listDestination = + roomsData?.rooms.find((r) => r.id === currentRoomId)?.destinations?.[0] ?? + null; + const detailDestination = + roomDetail?.id === currentRoomId + ? (roomDetail.destinations?.[0] ?? null) + : null; + const destination = listDestination ?? detailDestination; + const mapMetaReady = + roomsFetched || (roomDetailFetched && Boolean(destination?.trim())); + + return { destination, mapMetaReady }; +} diff --git a/src/components/map/MapCloseButton.tsx b/src/components/map/MapCloseButton.tsx new file mode 100644 index 00000000..bca7de57 --- /dev/null +++ b/src/components/map/MapCloseButton.tsx @@ -0,0 +1,24 @@ +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** 지도 위에 띄우는 작은 닫기 버튼 — 카테고리 탐색 닫기와 경로 보기 닫기가 함께 쓴다 */ +export function MapCloseButton({ + label, + onClick, + className, +}: Readonly<{ label: string; onClick: () => void; className?: string }>) { + return ( + + ); +} diff --git a/src/components/map/MapDiscoverToolbar.tsx b/src/components/map/MapDiscoverToolbar.tsx index 4c257b01..c32381b1 100644 --- a/src/components/map/MapDiscoverToolbar.tsx +++ b/src/components/map/MapDiscoverToolbar.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import { flushSync } from "react-dom"; import { AnimatePresence, motion } from "framer-motion"; -import { X } from "lucide-react"; import { CircleCancelIcon, SearchIcon } from "@/assets/icons"; import { useMobileView } from "@/contexts/MobileViewContext"; @@ -17,6 +16,7 @@ import { import { cn } from "@/lib/utils"; import MapFilter from "./MapFilter"; +import { MapCloseButton } from "./MapCloseButton"; import { MapCategoryChips } from "./MapCategoryChips"; import type { OpenValue, RatingValue } from "./map-filters"; import { mapToolbarPanelMotion } from "./map-toolbar-motion"; @@ -82,14 +82,7 @@ export function MapDiscoverToolbar({ setOpenNow={setOpenNow} onRatingDropdownOpenChange={setRatingDropdownOpen} /> - + onSelectCategory(null)} /> )} diff --git a/src/components/map/MapWithDetailPanel.tsx b/src/components/map/MapWithDetailPanel.tsx index 704645c4..22f52b4c 100644 --- a/src/components/map/MapWithDetailPanel.tsx +++ b/src/components/map/MapWithDetailPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useState, type ReactNode } from "react"; import { PlaceDetailPanel } from "@/components/place/PlaceDetailPanel"; import { MapToolbarLayoutProvider } from "@/contexts/MapToolbarLayoutContext"; @@ -8,15 +8,23 @@ import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; import { useMapDiscoverToolbarOffset } from "@/hooks/useMapDiscoverToolbarOffset"; import { cn } from "@/lib/utils"; -import Map from "./Map"; +import Map, { type MapRouteView } from "./Map"; import { MobileMapSearch } from "./MobileMapSearch"; export function MapWithDetailPanel({ mobileInline = false, hidden = false, + routeView, + onOpenRouteView, + children, }: { mobileInline?: boolean; hidden?: boolean; + /** 모바일 경로 보기 — 검색창을 숨기고 지도에는 고른 일차만 그린다 */ + routeView?: MapRouteView; + onOpenRouteView?: () => void; + /** 지도 위에 얹는 화면(경로 보기 카드·일차 탭 등) */ + children?: ReactNode; }) { const { selectedPlace, setSelectedPlace, onBack } = useSelectedPlace(); @@ -43,9 +51,11 @@ export function MapWithDetailPanel({ )} > - + + {children} + {/* Detail panel — 데스크톱: 지도 왼쪽에서 슬라이드 / 모바일: 헤더 아래 12px까지 펼쳐지는 바텀 시트 */}
- {mobileInline ? : null} + {mobileInline && !routeView ? : null} ); } diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 7210d665..2d27d4d8 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -45,17 +45,37 @@ import { PlanItineraryStopMapPin } from "./PlanItineraryStopMapPin"; /** * 일차별 "지도에 경로 표시" 토글(`usePlanScheduleRouteVisibilityStore`) ON인 * 일차만 지도에 경로(polyline)와 정류장 마커를 그립니다. + * `routeDay`가 있으면(모바일 경로 보기) 토글과 관계없이 그 일차만 그립니다. * 폴리라인 travelMode는 서버에 저장된 구간별 공유 이동수단을 따릅니다. * STOMP 에폭은 {@link usePlanMapDirectionsEpochStore} 참고. */ -export function PlanItineraryMapRoutes() { +/** 모바일 경로 보기 — 고른 일차 하나만 그린다 */ +export type PlanItineraryRouteDay = { + /** 일차를 아직 못 정했으면(불러오는 중·일차 없음) 아무것도 그리지 않는다 */ + scheduleId: number | null; + /** 일정 화면과 같은 일차 색 */ + color: string; + /** 카드로 고른 장소 — 핀을 크게 강조한다 */ + focusedItemId: number | null; + /** 일차 구간 경로 batch(`usePrefetchScheduleRoutes`)가 끝나기 전에는 구간을 조회하지 않는다 */ + routesReady: boolean; + onStopClick: (itemId: number) => void; +}; + +export function PlanItineraryMapRoutes({ + routeDay, +}: { + routeDay?: PlanItineraryRouteDay; +} = {}) { const roomIdRaw = useSessionStore((s) => s.currentRoomId); const rid = typeof roomIdRaw === "string" ? roomIdRaw.trim() : ""; const queryClient = useQueryClient(); const { isSuccess: schedulesHydrated } = useRoomSchedules(rid || null); const { selectedPlace, setSelectedPlace } = useSelectedPlace(); + // 경로 보기는 선택 장소도 일차 핀으로 강조해 보여 준다 — 일반 선택 핀으로 바꾸지 않는다 const selectedNorm = + !routeDay && selectedPlace?.googlePlaceId && selectedPlace.googlePlaceId.trim().length > 0 ? normalizeGooglePlaceResourceId(selectedPlace.googlePlaceId.trim()) @@ -67,14 +87,18 @@ export function PlanItineraryMapRoutes() { const visibilityOrder = usePlanScheduleRouteVisibilityStore( (s) => s.visibilityOrder, ); + // 경로 보기 값은 렌더마다 새 객체라, 메모·쿼리 옵션에서는 아래 원시값만 읽는다 + const routeDayScheduleId = routeDay?.scheduleId ?? null; + const inRouteView = routeDay != null; + const routesReady = routeDay?.routesReady ?? true; const visibleScheduleIds = useMemo( - () => - Object.keys(visibleByScheduleId) - .map((k) => Number(k)) - .filter( - (id) => Number.isFinite(id) && visibleByScheduleId[id] === true, - ), - [visibleByScheduleId], + () => { + if (inRouteView) return routeDayScheduleId !== null ? [routeDayScheduleId] : []; + return Object.keys(visibleByScheduleId) + .map(Number) + .filter((id) => Number.isFinite(id) && visibleByScheduleId[id] === true); + }, + [inRouteView, routeDayScheduleId, visibleByScheduleId], ); const directionsEpoch = usePlanMapDirectionsEpochStore((s) => @@ -92,13 +116,16 @@ export function PlanItineraryMapRoutes() { [visibleScheduleIds], ); + const routeDayColor = routeDay?.color ?? null; const routeColorByScheduleId = useMemo( () => - scheduleIdsToRouteColors( - sortedScheduleIdsForRouteColors(visibleByScheduleId), - rid || undefined, - ), - [visibleByScheduleId, rid], + routeDayScheduleId !== null && routeDayColor + ? new Map([[routeDayScheduleId, routeDayColor]]) + : scheduleIdsToRouteColors( + sortedScheduleIdsForRouteColors(visibleByScheduleId), + rid || undefined, + ), + [routeDayColor, routeDayScheduleId, visibleByScheduleId, rid], ); const fallbackRouteStroke = "#f12d33"; @@ -119,8 +146,9 @@ export function PlanItineraryMapRoutes() { const buckets = orderedScheduleIdsForQueries.map( (_, idx) => placesQueries[idx]?.data ?? [], ); + // 경로 보기는 일차 구간 경로 batch가 끝난 뒤에 구간을 조회한다(그 전엔 핀만 그린다) const segments = flattenPlanItinerarySegmentsFromPlaces( - orderedScheduleIdsForQueries, + routesReady ? orderedScheduleIdsForQueries : [], buckets, ); @@ -290,8 +318,12 @@ export function PlanItineraryMapRoutes() { }, [orderedScheduleIdsForQueries, buckets, selectedNorm]); const visibleStops = useMemo( - () => filterVisiblePlanMapStops(stopsForOffset, visibilityOrder), - [stopsForOffset, visibilityOrder], + () => + filterVisiblePlanMapStops( + stopsForOffset, + inRouteView ? visibleScheduleIds : visibilityOrder, + ), + [inRouteView, stopsForOffset, visibilityOrder, visibleScheduleIds], ); const placeByScheduleAndItemId = useMemo(() => { @@ -324,13 +356,20 @@ export function PlanItineraryMapRoutes() { const legacyId = normalizeGooglePlaceResourceId(gid); const dayColor = routeColorByScheduleId.get(scheduleId) ?? fallbackRouteStroke; + const focused = routeDay != null && routeDay.focusedItemId === place.itemId; + const itemId = place.itemId; stopMarkers.push( { e.stop(); + if (routeDay && typeof itemId === "number") { + routeDay.onStopClick(itemId); + return; + } setSelectedPlace( { name: place.title, @@ -347,6 +386,7 @@ export function PlanItineraryMapRoutes() { , diff --git a/src/components/map/PlanItineraryStopMapPin.tsx b/src/components/map/PlanItineraryStopMapPin.tsx index 84a809b1..1f953b75 100644 --- a/src/components/map/PlanItineraryStopMapPin.tsx +++ b/src/components/map/PlanItineraryStopMapPin.tsx @@ -3,16 +3,15 @@ import { MapPinIconWithoutCircle } from "@/components/icons"; import { cn } from "@/lib/utils"; -import { - MAP_PIN_DISPLAY_SIZE_PX, - mapPinBodyBorderProps, -} from "./map-pin-stroke"; +import { MAP_PIN_DISPLAY_SIZE_PX, mapPinBodyBorderProps } from "./map-pin-stroke"; export type PlanItineraryStopMapPinProps = { /** 표시 순번 (1부터). 호출부에서 `orderIdx + 1` 전달 권장 */ orderLabel: number; /** 폴리라인 일차색과 동일한 hex. 없으면 primary와 동등 */ pinColor?: string; + /** 경로 보기에서 카드로 고른 장소 — 핀을 살짝 키운다 */ + emphasized?: boolean; className?: string; }; @@ -22,6 +21,7 @@ export type PlanItineraryStopMapPinProps = { export function PlanItineraryStopMapPin({ orderLabel, pinColor, + emphasized = false, className, }: PlanItineraryStopMapPinProps) { const twoDigits = orderLabel >= 10; @@ -30,25 +30,28 @@ export function PlanItineraryStopMapPin({ return ( - 0 ? trimmed : "currentColor"} - {...mapPinBodyBorderProps} - /> - - {orderLabel} + {/* 크기는 그대로 두고 핀 끝을 기준으로 키운다(마커 기준점·순번 글자 비율도 그대로) */} + + 0 ? trimmed : "currentColor"} + {...mapPinBodyBorderProps} + /> + + {orderLabel} + ); diff --git a/src/components/map/RouteViewCameraController.test.tsx b/src/components/map/RouteViewCameraController.test.tsx new file mode 100644 index 00000000..2935ba9d --- /dev/null +++ b/src/components/map/RouteViewCameraController.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { act } from "react"; +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 })); +vi.mock("@vis.gl/react-google-maps", () => ({ useMap: () => map })); +import { RouteViewCameraController } from "./RouteViewCameraController"; + +let host: HTMLDivElement; +let root: ReturnType; +const first = { itemId: 1, lat: 37.5, lng: 127 }; +const second = { itemId: 2, lat: 37.7, lng: 128 }; +const camera: MapRouteView["camera"] = { kind: "pan", seq: 1 }; + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + map.panTo.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()); +} + +it("선택 장소 위치가 늦게 도착하면 그 위치로 이동한다", async () => { + await render(2, [first]); + expect(map.panTo).not.toHaveBeenCalled(); + await render(2, [first, second]); + expect(map.panTo).toHaveBeenCalledWith(expect.objectContaining({ lng: 128 })); +}); + +it("삭제 후 선택이 다음 장소로 바뀌면 같은 카메라 요청에서도 이동한다", async () => { + await render(1, [first, second]); + map.panTo.mockClear(); + await render(2, [second]); + expect(map.panTo).toHaveBeenCalledWith(expect.objectContaining({ lng: 128 })); +}); diff --git a/src/components/map/RouteViewCameraController.tsx b/src/components/map/RouteViewCameraController.tsx new file mode 100644 index 00000000..598e6823 --- /dev/null +++ b/src/components/map/RouteViewCameraController.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useMap } from "@vis.gl/react-google-maps"; +import type { MapRouteView } from "./Map"; + +/** 접힌 카드·일차 탭·X가 가리는 지도 아래쪽 높이 */ +const ROUTE_VIEW_COLLAPSED_COVER_PX = 300; +/** 펼친 카드가 가리는 지도 높이 비율(카드 70% + 아래 여백) */ +const ROUTE_VIEW_EXPANDED_COVER_RATIO = 0.72; +const ROUTE_VIEW_EDGE_PADDING = 40; +/** 장소 하나만 볼 때(장소 하나뿐인 일차·일정 카드로 들어올 때)의 줌 — 주변 길이 보이는 정도 */ +const ROUTE_VIEW_SINGLE_PLACE_ZOOM = 15; + +/** + * 가린 영역을 뺀 나머지의 가운데에 `target`이 오도록 옮긴다. + * 지도를 막 불러온 직후엔 `getProjection()`이 없을 수 있어 줌·위도로 직접 계산한다 + * (메르카토르에서 위도 1px ≈ 경도 1px × cos(위도)). + */ +function panToVisibleCenter( + map: google.maps.Map, + target: google.maps.LatLngLiteral, + coveredPx: number, + zoom = map.getZoom(), +) { + if (zoom === undefined || coveredPx <= 0) { + map.panTo(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 }); +} + +/** 지도 왼쪽·위쪽에서 `target`까지의 거리(px) — 지도를 아직 그리지 않았으면 null */ +function offsetInMapPx(map: google.maps.Map, target: google.maps.LatLngLiteral) { + const projection = map.getProjection(); + const bounds = map.getBounds(); + const zoom = map.getZoom(); + if (!projection || !bounds || zoom === undefined) return null; + const topRight = projection.fromLatLngToPoint(bounds.getNorthEast()); + const bottomLeft = projection.fromLatLngToPoint(bounds.getSouthWest()); + const point = projection.fromLatLngToPoint(target); + if (!topRight || !bottomLeft || !point) return null; + const scale = 2 ** zoom; + return { x: (point.x - bottomLeft.x) * scale, y: (point.y - topRight.y) * scale }; +} + +export function RouteViewCameraController({ + camera, + locations, + focusedItemId, + expanded, + candidate, +}: Pick) { + const map = useMap(); + const appliedRef = useRef<{ map: google.maps.Map; key: string } | null>(null); + const candidateFocused = candidate?.focused ?? false; + const candidateLat = candidate?.location?.lat; + const candidateLng = candidate?.location?.lng; + + // 후보 카드를 볼 때 — 누른 자리 그대로 두고, 카드에 가려질 때만 보이는 영역 가운데로 옮긴다 + useEffect(() => { + if (!map || !candidateFocused || candidateLat === undefined || candidateLng === undefined) return; + const target = { lat: candidateLat, lng: candidateLng }; + const coveredPx = expanded + ? Math.round(map.getDiv().clientHeight * ROUTE_VIEW_EXPANDED_COVER_RATIO) + : ROUTE_VIEW_COLLAPSED_COVER_PX; + const offset = offsetInMapPx(map, target); + const { clientWidth, clientHeight } = map.getDiv(); + // 핀(고르면 약 55px)이 위로 그려지므로 위쪽 여유를 두고, 좌우 가장자리에 걸려도 옮긴다 + if ( + offset && + offset.y > 72 && + offset.y < clientHeight - coveredPx - 16 && + offset.x > 36 && + offset.x < clientWidth - 36 + ) { + return; + } + panToVisibleCenter(map, target, coveredPx); + }, [map, candidateFocused, candidateLat, candidateLng, expanded]); + + useEffect(() => { + // 후보 카드를 볼 때는 고른 장소(focusedItemId)가 없어 pan은 아무 일도 하지 않는다 + if (!map || locations.length === 0) return; + // 같은 요청을 두 번 실행하지 않는다 — fit은 장소가 처음 채워질 때도 다시 맞춘다 + const locationsKey = locations.map((l) => `${l.itemId}:${l.lat},${l.lng}`).join("|"); + const target = locations.find((l) => l.itemId === focusedItemId); + if (camera.kind !== "fit" && !target) return; + const key = + camera.kind === "fit" + ? `fit:${camera.seq}:${expanded}:${locationsKey}` + : `${camera.kind}:${camera.seq}:${expanded}:${target?.itemId}:${target?.lat},${target?.lng}`; + if (appliedRef.current?.map === map && appliedRef.current.key === key) return; + appliedRef.current = { map, key }; + + const coveredPx = expanded + ? Math.round(map.getDiv().clientHeight * ROUTE_VIEW_EXPANDED_COVER_RATIO) + : ROUTE_VIEW_COLLAPSED_COVER_PX; + const toLatLng = (l: (typeof locations)[number]) => ({ lat: l.lat, lng: l.lng }); + + if (camera.kind === "pan") { + if (target) panToVisibleCenter(map, toLatLng(target), coveredPx); + return; + } + if (camera.kind === "place" && target) { + map.setZoom(ROUTE_VIEW_SINGLE_PLACE_ZOOM); + panToVisibleCenter(map, toLatLng(target), coveredPx, ROUTE_VIEW_SINGLE_PLACE_ZOOM); + return; + } + if (locations.length === 1) { + map.setZoom(ROUTE_VIEW_SINGLE_PLACE_ZOOM); + panToVisibleCenter(map, toLatLng(locations[0]!), 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, + }); + }, [map, camera.kind, camera.seq, locations, focusedItemId, expanded]); + + return null; +} + diff --git a/src/components/map/index.ts b/src/components/map/index.ts index b282d385..22d38c4b 100644 --- a/src/components/map/index.ts +++ b/src/components/map/index.ts @@ -1 +1,2 @@ export { MapWithDetailPanel } from "./MapWithDetailPanel"; +export type { MapRouteView } from "./Map"; diff --git a/src/components/mobile/DashedPlaceholderBox.tsx b/src/components/mobile/DashedPlaceholderBox.tsx new file mode 100644 index 00000000..0cf89ef8 --- /dev/null +++ b/src/components/mobile/DashedPlaceholderBox.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +/** + * 긴 점선 테두리 — 감싼 `relative` 요소 위에 겹쳐 그린다. + * CSS 점선은 점 길이를 못 바꿔서 SVG로 그린다. `active`면 primary 색(놓을 자리·넣을 자리)이다. + */ +export function DashedBorder({ active = false, className }: Readonly<{ active?: boolean; className?: string }>) { + return ( + + + + ); +} + +/** + * 비어 있는 자리를 알리는 긴 점선 박스 — 일정 화면의 빈 일차, 지도 경로 보기의 장소 없는 일차가 함께 쓴다. + * `active`면 놓을 자리로 강조한다. + */ +export function DashedPlaceholderBox({ + active = false, + className, + children, +}: Readonly<{ active?: boolean; className?: string; children: ReactNode }>) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/components/mobile/MobileMainTabs.tsx b/src/components/mobile/MobileMainTabs.tsx index 862c33e8..569908d2 100644 --- a/src/components/mobile/MobileMainTabs.tsx +++ b/src/components/mobile/MobileMainTabs.tsx @@ -1,10 +1,16 @@ "use client"; import Link from "next/link"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname } from "next/navigation"; import { useMobileView } from "@/contexts/MobileViewContext"; -import { buildMobilePlanPanelHref, readMobilePlanPanel } from "@/lib/mobile-view"; +import { + isChatPathname, + isMobileMapPathname, + isPlanPathname, + CHAT_PATH, + mobileScheduleHref, +} from "@/lib/mobile-view"; import { SidebarChatUnreadBadge } from "@/components/layout/SidebarChatUnreadBadge"; import { cn } from "@/lib/utils"; @@ -18,17 +24,15 @@ const tabIcons = { /** 모바일 전용 하단 탭 바 — 지도 전환은 상단 헤더에서 제공합니다. */ export function MobileMainTabs() { const pathname = usePathname(); - const searchParams = useSearchParams(); const { isMobileDevice } = useMobileView(); if (!isMobileDevice) return null; - const isPlan = pathname === "/plan" || pathname.startsWith("/plan/"); - const currentPanel = readMobilePlanPanel(searchParams.get("view")); + // 지도는 일정 탭에서 헤더 버튼으로 여는 화면이라 일정 탭을 활성으로 둔다 const tabs = [ - { label: "일정", href: buildMobilePlanPanelHref(pathname, "schedule"), icon: tabIcons.schedule, active: isPlan && currentPanel !== "chat" }, + { label: "일정", href: mobileScheduleHref(pathname), icon: tabIcons.schedule, active: isPlanPathname(pathname) || isMobileMapPathname(pathname) }, { label: "북마크", href: "/bookmark", icon: tabIcons.bookmark, active: pathname === "/bookmark" || pathname.startsWith("/bookmark/") }, { label: "여행 도구", href: "/cost", icon: tabIcons.tools, active: pathname === "/cost" || pathname.startsWith("/cost/") || pathname.startsWith("/packing/") }, - { label: "채팅", href: buildMobilePlanPanelHref(pathname, "chat"), icon: tabIcons.chat, active: isPlan && currentPanel === "chat" }, + { label: "채팅", href: CHAT_PATH, icon: tabIcons.chat, active: isChatPathname(pathname) }, ]; return (