From 14f48c0494b5af78f63ed7d7225d7fe4b3797d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Thu, 8 Oct 2026 18:46:27 +0900 Subject: [PATCH 01/11] =?UTF-8?q?chore(plan-insert):=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98,=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/icons/index.tsx | 4 ++++ src/assets/icons/location-add.svg | 6 ++++++ src/assets/icons/saved-add.svg | 5 +++++ src/lib/plan/insertPosition.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 38 insertions(+) create mode 100644 src/assets/icons/location-add.svg create mode 100644 src/assets/icons/saved-add.svg create mode 100644 src/lib/plan/insertPosition.test.ts diff --git a/src/assets/icons/index.tsx b/src/assets/icons/index.tsx index fc2f041c..2cfac24a 100644 --- a/src/assets/icons/index.tsx +++ b/src/assets/icons/index.tsx @@ -7,12 +7,14 @@ import CircleCancelSvg from "@/assets/icons/circle-cancel.svg"; import CloseSvg from "@/assets/icons/close.svg"; import CoinSvg from "@/assets/icons/coin.svg"; import LinkSvg from "@/assets/icons/link.svg"; +import LocationAddSvg from "@/assets/icons/location-add.svg"; import LocationErrorSvg from "@/assets/icons/location-error.svg"; import LocationSvg from "@/assets/icons/location.svg"; 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 SavedAddSvg from "@/assets/icons/saved-add.svg"; import SavedSvg from "@/assets/icons/saved.svg"; import SearchSvg from "@/assets/icons/search.svg"; import SettingsSvg from "@/assets/icons/settings.svg"; @@ -35,12 +37,14 @@ export const CircleCancelIcon = createIcon(CircleCancelSvg, "CircleCancelIcon"); export const CloseIcon = createIcon(CloseSvg, "CloseIcon"); export const CoinIcon = createIcon(CoinSvg, "CoinIcon"); export const LinkIcon = createIcon(LinkSvg, "LinkIcon"); +export const LocationAddIcon = createIcon(LocationAddSvg, "LocationAddIcon"); export const LocationErrorIcon = createIcon(LocationErrorSvg, "LocationErrorIcon"); export const LocationIcon = createIcon(LocationSvg, "LocationIcon"); export const MenuDotVerticalIcon = createIcon(MenuDotVerticalSvg, "MenuDotVerticalIcon"); export const MenuHamburgerIcon = createIcon(MenuHamburgerSvg, "MenuHamburgerIcon"); export const PlusIcon = createIcon(PlusSvg, "PlusIcon"); export const ReactionStarIcon = createIcon(ReactionStarSvg, "ReactionStarIcon"); +export const SavedAddIcon = createIcon(SavedAddSvg, "SavedAddIcon"); export const SavedIcon = createIcon(SavedSvg, "SavedIcon"); export const SearchIcon = createIcon(SearchSvg, "SearchIcon"); export const SettingsIcon = createIcon(SettingsSvg, "SettingsIcon"); diff --git a/src/assets/icons/location-add.svg b/src/assets/icons/location-add.svg new file mode 100644 index 00000000..9310101e --- /dev/null +++ b/src/assets/icons/location-add.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/saved-add.svg b/src/assets/icons/saved-add.svg new file mode 100644 index 00000000..61a46d53 --- /dev/null +++ b/src/assets/icons/saved-add.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/lib/plan/insertPosition.test.ts b/src/lib/plan/insertPosition.test.ts new file mode 100644 index 00000000..3cbf9659 --- /dev/null +++ b/src/lib/plan/insertPosition.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { insertPositionAfterItem } from "./insertPosition"; +import type { PlanPlace } from "./types"; + +const place = (itemId: number) => ({ id: `p${itemId}`, itemId }) as PlanPlace; +const places = [place(10), place(20), place(30)]; + +describe("insertPositionAfterItem", () => { + it("기준 장소 바로 뒤 위치를 돌려준다", () => { + expect(insertPositionAfterItem(places, 10)).toEqual({ index: 1, anchorMissing: false }); + expect(insertPositionAfterItem(places, 30)).toEqual({ index: 3, anchorMissing: false }); + }); + + it("기준이 없으면 맨 뒤", () => { + expect(insertPositionAfterItem(places, null)).toEqual({ index: 3, anchorMissing: false }); + expect(insertPositionAfterItem([], null)).toEqual({ index: 0, anchorMissing: false }); + }); + + it("기준 장소가 삭제됐으면 맨 뒤에 넣고 알린다", () => { + expect(insertPositionAfterItem(places, 99)).toEqual({ index: 3, anchorMissing: true }); + }); +}); From 56a8f2c75be9cf11fcfc95d848c734bb2fd4fdf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Thu, 8 Oct 2026 19:10:53 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feature(plan-insert):=20=EB=AA=A8?= =?UTF-8?q?=EB=B0=94=EC=9D=BC=20=EC=9D=BC=EC=A0=95=20=EC=A4=91=EA=B0=84?= =?UTF-8?q?=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itinerary/AddFromBookmarkModal.tsx | 9 +- .../mobile/MobilePlanDaySection.tsx | 14 ++- .../mobile/MobilePlanItinerary.tsx | 86 +++++++++++++++++-- .../mobile/MobilePlanPlaceCard.tsx | 9 +- .../_components/mobile/MobilePlanView.tsx | 46 ++++++++-- src/lib/plan/insertPosition.test.ts | 41 ++++++--- src/lib/plan/insertPosition.ts | 37 ++++++++ 7 files changed, 209 insertions(+), 33 deletions(-) create mode 100644 src/lib/plan/insertPosition.ts diff --git a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx index 842d97d6..4a314514 100644 --- a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx +++ b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx @@ -21,7 +21,7 @@ import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; import { bucketItemCount } from "@/lib/analytics/context"; import { placePreviewQueryOptions } from "@/lib/places/place-queries"; import type { PlacePreview } from "@/lib/api/places"; -import type { RoomBookmark } from "@/lib/api/rooms"; +import type { RoomBookmark, RoomScheduleItem } from "@/lib/api/rooms"; import type { PlanPlace } from "@/lib/plan/types"; import { cn } from "@/lib/utils"; @@ -31,6 +31,8 @@ type AddFromBookmarkModalProps = { places: PlanPlace[]; /** 지정 시 해당 index에 삽입, 없으면 맨 뒤 */ insertIndex?: number; + /** 추가 성공 직후 — 모바일에서 새 카드로 스크롤할 때 쓴다 */ + onAdded?: (item: RoomScheduleItem) => void; onClose: () => void; }; @@ -39,6 +41,7 @@ export function AddFromBookmarkModal({ scheduleId, places, insertIndex, + onAdded, onClose, }: AddFromBookmarkModalProps) { const [selectedCategoryId, setSelectedCategoryId] = useState( @@ -160,7 +163,7 @@ export function AddFromBookmarkModal({ setAddingGooglePlaceId(gid); const index = insertIndex ?? places.length; try { - await createItem({ + const created = await createItem({ roomId, scheduleId, googlePlaceId: gid, @@ -172,6 +175,7 @@ export function AddFromBookmarkModal({ interaction_source: "bookmark", }); toast.success("일정에 추가했어요."); + onAdded?.(created); onClose(); } catch { toast.error("장소를 일정에 추가하지 못했어요."); @@ -184,6 +188,7 @@ export function AddFromBookmarkModal({ createItem, existingPlaceIds, insertIndex, + onAdded, onClose, places, roomId, diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx index 44466517..a2af8bcb 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx @@ -15,8 +15,8 @@ import { MobileSheetMenuItem, } from "@/components/mobile/MobileBottomSheet"; import { expensesInScope } from "@/lib/expenses/expense-scope"; +import type { InsertAnchor } from "@/lib/plan/insertPosition"; import { summarizeExpensesForMobile } from "@/lib/plan/mobilePlanFormat"; -import type { PlanPlace } from "@/lib/plan/types"; import { MobilePlanItinerary } from "./MobilePlanItinerary"; @@ -36,7 +36,11 @@ type MobilePlanDaySectionProps = Readonly<{ onFinishEditing: () => void; onRequestInsertDayAfter: () => void; onRequestDeleteDay: () => void; - onRequestAddPlace: (scheduleId: number, places: PlanPlace[]) => void; + /** 전체 화면 검색으로 장소 추가 — anchor 자리에, null이면 맨 뒤 */ + onRequestAddPlace: (scheduleId: number, anchor: InsertAnchor | null) => void; + /** 방금 추가한 장소 — 카드로 스크롤하고 잠깐 강조한다 */ + recentlyAddedItemId: number | null; + onPlaceAdded: (itemId: number) => void; }>; /** 모바일 일차 한 개 — 헤더(순서 편집 ↑↓ / ⚙ 일정 관리)와 장소 목록 */ @@ -53,6 +57,8 @@ export function MobilePlanDaySection({ onRequestInsertDayAfter, onRequestDeleteDay, onRequestAddPlace, + recentlyAddedItemId, + onPlaceAdded, }: MobilePlanDaySectionProps) { const expenses = useExpenseContext(); const [manageOpen, setManageOpen] = useState(false); @@ -108,7 +114,9 @@ export function MobilePlanDaySection({ roomId={roomId} scheduleId={scheduleId} monthDayLabel={monthDayLabel} - onRequestAddPlace={(places) => onRequestAddPlace(scheduleId, places)} + onRequestAddPlace={(anchor) => onRequestAddPlace(scheduleId, anchor)} + recentlyAddedItemId={recentlyAddedItemId} + onPlaceAdded={onPlaceAdded} /> setManageOpen(false)} title="일정 관리"> diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx index 6fd9f315..b3ba2f41 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx @@ -1,12 +1,15 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useReducedMotion } from "framer-motion"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PlanLoadingSkeleton } from "../PlanLoadingSkeleton"; import { toast } from "sonner"; import { CoinIcon, + LocationAddIcon, PlusIcon, + SavedAddIcon, TimeClockIcon, TrashIcon, WriteAddIcon, @@ -25,6 +28,11 @@ import { bucketItemCount } from "@/lib/analytics/context"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; import { expensesInScope } from "@/lib/expenses/expense-scope"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; +import { + insertAnchorAfterItem, + resolveInsertIndex, + type InsertAnchor, +} from "@/lib/plan/insertPosition"; import { summarizeExpensesForMobile } from "@/lib/plan/mobilePlanFormat"; import { planCopy } from "@/lib/plan/planCopy"; import { schedulePlacesFingerprint } from "@/lib/plan/planTravelLocalStorage"; @@ -56,8 +64,11 @@ type MobilePlanItineraryProps = Readonly<{ scheduleId: number; /** `9월 21일` — 방문 시간 시트에 표시 */ monthDayLabel?: string; - /** 전체 화면 검색으로 이 일차에 장소 추가 — 탭 제스처 안에서 호출해야 한다 */ - onRequestAddPlace: (places: PlanPlace[]) => void; + /** 전체 화면 검색으로 이 일차에 장소 추가 — anchor 자리에, null이면 맨 뒤. 탭 제스처 안에서 호출해야 한다 */ + onRequestAddPlace: (anchor: InsertAnchor | null) => void; + /** 방금 추가한 장소 — 이 일차에 있으면 카드로 스크롤하고 강조한다(해제는 상위에서) */ + recentlyAddedItemId: number | null; + onPlaceAdded: (itemId: number) => void; }>; /** 모바일 일차별 장소 목록 — 카드·이동 요약·추가 버튼, 편집 모드에서는 핸들로 순서를 바꾼다(다른 일차로도) */ @@ -66,6 +77,8 @@ export function MobilePlanItinerary({ scheduleId, monthDayLabel, onRequestAddPlace, + recentlyAddedItemId, + onPlaceAdded, }: MobilePlanItineraryProps) { const expenses = useExpenseContext(); const openPlaceOnMap = useOpenPlaceOnMap(); @@ -105,7 +118,8 @@ export function MobilePlanItinerary({ ); const [sheet, setSheet] = useState(null); - const [bookmarkOpen, setBookmarkOpen] = useState(false); + /** anchor: 장소 사이에 넣을 기준, null이면 맨 뒤 */ + const [bookmarkTarget, setBookmarkTarget] = useState<{ anchor: InsertAnchor | null } | null>(null); const { mutateAsync: removeItem, isPending: isRemoving } = useDeleteScheduleItem(); const itemExpenses = useCallback( @@ -157,6 +171,20 @@ export function MobilePlanItinerary({ } } + // ─── 방금 추가한 카드로 스크롤 + 강조 ───────────────────────────────── + // 검색·북마크 화면은 오버레이라 목록 스크롤은 그대로 남아 있다. 새 카드가 화면 밖일 때만 최소한으로 움직인다 + const listRef = useRef(null); + const prefersReducedMotion = useReducedMotion(); + // 같은 장소로 두 번 스크롤하지 않게 — 강조 중 목록이 갱신돼도 다시 끌어오지 않는다 + const scrolledItemIdRef = useRef(null); + useEffect(() => { + if (recentlyAddedItemId === null || scrolledItemIdRef.current === recentlyAddedItemId) return; + const row = listRef.current?.querySelector(`[data-plan-item-id="${recentlyAddedItemId}"]`); + if (!row) return; + scrolledItemIdRef.current = recentlyAddedItemId; + row.scrollIntoView({ block: "nearest", behavior: prefersReducedMotion ? "auto" : "smooth" }); + }, [places, prefersReducedMotion, recentlyAddedItemId]); + // ─── 렌더 ──────────────────────────────────────────────────────────── const sheetPlace = sheet?.place; const sheetItemId = typeof sheetPlace?.itemId === "number" ? sheetPlace.itemId : null; @@ -166,7 +194,10 @@ export function MobilePlanItinerary({
drag.registerList(scheduleId, el)} + ref={(el) => { + listRef.current = el; + drag.registerList(scheduleId, el); + }} className={editing ? "flex flex-col gap-2" : "flex flex-col"} > {places.length === 0 && !isLoading && !isError ? ( @@ -177,6 +208,7 @@ export function MobilePlanItinerary({ return (
drag.registerRow(scheduleId, index, el)} style={drag.rowStyle(scheduleId, index)} > @@ -184,6 +216,7 @@ export function MobilePlanItinerary({ place={place} orderNumber={index + 1} badgeColor={badgeColor} + highlighted={recentlyAddedItemId !== null && place.itemId === recentlyAddedItemId} expenseSummary={summarizeExpensesForMobile(itemExpenses(place.itemId))} onOpen={() => openPlace(place)} onOpenActions={() => setSheet({ kind: "actions", place })} @@ -212,7 +245,7 @@ export function MobilePlanItinerary({
@@ -337,16 +383,23 @@ function MobilePlanActionSheet({ 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 ( @@ -357,6 +410,21 @@ function MobilePlanActionSheet({

) : ( <> + {canInsert ? ( + <> + onInsertFromSearch(itemId)} + /> + onInsertFromBookmark(itemId)} + /> +
+ + ) : null} void; @@ -44,6 +46,7 @@ export function MobilePlanPlaceCard({ place, orderNumber, badgeColor, + highlighted = false, expenseSummary, onOpen, onOpenActions, @@ -62,7 +65,11 @@ export function MobilePlanPlaceCard({ 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, returnPath: "/plan", openedInApp: false }); + history.length = 0; 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 +73,68 @@ 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으로 바꾼다 + useChatPanelStore.setState({ minimized: false, openedInApp: false, returnPath: "/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("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 +154,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 +216,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 +261,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 +281,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 +329,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/hooks/lifecycle-regressions.test.tsx b/src/hooks/lifecycle-regressions.test.tsx index 1d8436db..dccb6f10 100644 --- a/src/hooks/lifecycle-regressions.test.tsx +++ b/src/hooks/lifecycle-regressions.test.tsx @@ -4,7 +4,6 @@ import { beforeEach, expect, it, vi } from "vitest"; import { hookHarness } from "@/test/hook-harness"; import { useOnClickOutside } from "./useOnClickOutside"; import { useSessionPromptVisible } from "./useSessionPromptVisible"; -import { usePlanChatPanelReveal } from "./usePlanChatPanelReveal"; import { useResolvedCurrentRoomId } from "./use-room-id"; import { useTripMapBootstrap } from "./useTripMapBootstrap"; import { usePrefetchScheduleRoutes } from "./usePrefetchScheduleRoutes"; @@ -83,26 +82,6 @@ it("URL room switches are resolved before children commit and storage never over expect(useSessionStore.getState().currentRoomId).toBe("b"); await hook.unmount(); }); -it("chat reveal fallback resets across close/reopen", async () => { - vi.useFakeTimers(); - const hook = hookHarness(usePlanChatPanelReveal); - const args = { - pathname: "/plan/a", - chatState: "maximized" as const, - isMobile: false, - targetMaxWidthPx: 400, - measuredLeftWidthPx: 900, - }; - await hook.render(args); - expect(hook.current).toBe(false); - await act(async () => vi.advanceTimersByTime(2000)); - expect(hook.current).toBe(true); - await hook.render({ ...args, chatState: "closed" }); - await hook.render(args); - expect(hook.current).toBe(false); - await hook.unmount(); - vi.useRealTimers(); -}); it("route batch gates a switched schedule immediately and ignores late completion", async () => { const pending: (() => void)[] = []; mocks.batch.mockImplementation( diff --git a/src/hooks/useRoomUnreadCount.test.tsx b/src/hooks/useRoomUnreadCount.test.tsx index 387b1adc..06df31b4 100644 --- a/src/hooks/useRoomUnreadCount.test.tsx +++ b/src/hooks/useRoomUnreadCount.test.tsx @@ -4,19 +4,19 @@ import { expect, it, vi } from "vitest"; import { hookHarness } from "@/test/hook-harness"; import { useRoomUnreadCount } from "./useRoomUnreadCount"; import { useChatPanelStore } from "@/stores/chat-panel-store"; -const state = vi.hoisted(() => ({ mobile: true, path: "/plan/room", view: "chat", fetch: vi.fn(async () => ({ unreadCount: 2 })) })); +const state = vi.hoisted(() => ({ mobile: true, path: "/chat", view: "", fetch: vi.fn(async () => ({ unreadCount: 2 })) })); vi.mock("next/navigation", () => ({ usePathname: () => state.path, useSearchParams: () => new URLSearchParams({ view: state.view }) })); vi.mock("@/contexts/MobileViewContext", () => ({ useMobileView: () => ({ isMobileDevice: state.mobile }) })); vi.mock("@/lib/api/rooms", () => ({ getRoomUnreadCount: state.fetch })); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); it("does not fetch while mobile chat is visible, and resumes when leaving chat", async () => { - useChatPanelStore.getState().closeChat(); + useChatPanelStore.setState({ minimized: false }); const client = new QueryClient(); const hook = hookHarness(() => useRoomUnreadCount("room"), child => {child}); try { await hook.render(undefined); expect(state.fetch).not.toHaveBeenCalled(); - state.view = "schedule"; + state.path = "/plan/room"; await hook.render(undefined); await hook.flush(); expect(hook.current.data).toEqual({ unreadCount: 2 }); @@ -28,13 +28,16 @@ it("does not fetch while mobile chat is visible, and resumes when leaving chat", it.each([ { mobile: false, path: "/plan/room", view: "chat", open: false, enabled: true }, { mobile: false, path: "/plan/room", view: "schedule", open: true, enabled: false }, - { mobile: true, path: "/plan/room", view: "map", open: true, enabled: true }, + { mobile: false, path: "/chat", view: "", open: false, enabled: false }, + { mobile: true, path: "/map", view: "", open: true, enabled: true }, + // 모바일 채팅은 `/chat` 경로로만 판단한다 — 예전 `?view=chat`은 새 경로로 옮겨진 뒤 열린다 + { mobile: true, path: "/plan/room", view: "chat", open: false, enabled: true }, { mobile: true, path: "/search", view: "chat", open: false, enabled: true }, ])("uses actual chat visibility: %j", async ({ mobile, path, view, open, enabled }) => { Object.assign(state, { mobile, path, view }); state.fetch.mockClear(); - if (open) useChatPanelStore.getState().openChat(); - else useChatPanelStore.getState().closeChat(); + // open: PC에서 다른 페이지 위에 최소화 채팅이 떠 있음 + useChatPanelStore.setState({ minimized: open }); const client = new QueryClient(); const hook = hookHarness(() => useRoomUnreadCount("room"), child => {child}); try { @@ -44,6 +47,6 @@ it.each([ } finally { await hook.unmount(); client.clear(); - useChatPanelStore.getState().closeChat(); + useChatPanelStore.setState({ minimized: false }); } }); diff --git a/src/lib/layout/mainChromeLayoutWidth.test.ts b/src/lib/layout/mainChromeLayoutWidth.test.ts index 52f74be1..5be2625f 100644 --- a/src/lib/layout/mainChromeLayoutWidth.test.ts +++ b/src/lib/layout/mainChromeLayoutWidth.test.ts @@ -1,36 +1,23 @@ import { expect, it } from "vitest"; import { resolveLeftSectionTargetMaxWidthPx, resolveLeftSectionMinWidthPx } from "./mainChromeLayoutWidth"; -it("keeps the schedule width across tabs and chat states, including page width effects", () => { - for (const pathname of ["/plan", "/plan/room", "/search", "/bookmark", "/bookmark/folder", "/member-settings", "/room-settings", "/settings"]) { - for (const chatState of ["closed", "minimized", "maximized"] as const) { - for (const contentWidthToken of ["", "400px"]) { - expect(resolveLeftSectionTargetMaxWidthPx({ pathname, contentWidthToken, chatState, isMobile: false })).toBe(720); - expect(resolveLeftSectionTargetMaxWidthPx({ pathname, contentWidthToken, chatState, isMobile: true })).toBeNull(); - } +it("keeps the schedule width across collaboration tabs and /chat, including page width effects", () => { + for (const pathname of ["/plan", "/plan/room", "/chat", "/search", "/bookmark", "/bookmark/folder", "/member-settings", "/room-settings", "/settings"]) { + for (const contentWidthToken of ["", "400px"]) { + expect(resolveLeftSectionTargetMaxWidthPx({ pathname, contentWidthToken, isMobile: false })).toBe(720); + expect(resolveLeftSectionTargetMaxWidthPx({ pathname, contentWidthToken, isMobile: true })).toBeNull(); } } }); -it("lets the ledger use the full desktop workspace", () => { - expect(resolveLeftSectionTargetMaxWidthPx({ pathname: "/cost", contentWidthToken: "", chatState: "closed", isMobile: false })).toBeNull(); -}); -it("packing uses the full available desktop width until chat is maximized", () => { - for (const chatState of ["closed", "minimized"] as const) { - expect(resolveLeftSectionTargetMaxWidthPx({ pathname: "/packing/room", contentWidthToken: "400px", chatState, isMobile: false })).toBeNull(); - } - expect(resolveLeftSectionTargetMaxWidthPx({ pathname: "/packing/room", contentWidthToken: "400px", chatState: "maximized", isMobile: false })).toBe(720); +it("lets the ledger and packing use the full desktop workspace", () => { + expect(resolveLeftSectionTargetMaxWidthPx({ pathname: "/cost", contentWidthToken: "", isMobile: false })).toBeNull(); + expect(resolveLeftSectionTargetMaxWidthPx({ pathname: "/packing/room", contentWidthToken: "400px", isMobile: false })).toBeNull(); }); it("removes the desktop minimum width only for packing", () => { expect(resolveLeftSectionMinWidthPx(false, "/packing/room")).toBe(0); - expect(resolveLeftSectionMinWidthPx(false, "/packing/room", "maximized")).toBe(400); expect(resolveLeftSectionMinWidthPx(false, "/plan/room")).toBe(400); + expect(resolveLeftSectionMinWidthPx(false, "/chat")).toBe(400); expect(resolveLeftSectionMinWidthPx(true, "/plan/room")).toBe(0); - -}); - -it("keeps ledger full width when chat is minimized and uses docked width when maximized", () => { - expect(resolveLeftSectionTargetMaxWidthPx({ pathname: "/cost", contentWidthToken: "", chatState: "minimized", isMobile: false })).toBeNull(); - expect(resolveLeftSectionTargetMaxWidthPx({ pathname: "/cost", contentWidthToken: "", chatState: "maximized", isMobile: false })).toBe(720); }); diff --git a/src/lib/mobile-view/plan-panel.test.ts b/src/lib/mobile-view/plan-panel.test.ts index bc82424a..972b4abb 100644 --- a/src/lib/mobile-view/plan-panel.test.ts +++ b/src/lib/mobile-view/plan-panel.test.ts @@ -1,42 +1,33 @@ import { describe, expect, it } from "vitest"; import { - buildMobilePlanPanelHref, - MOBILE_PLAN_PANEL_ORDER, - readMobilePlanPanel, + isChatPathname, + isMobileMapPathname, + isPlanPathname, + legacyPlanViewPath, + mobileScheduleHref, } from "./plan-panel"; -describe("mobile plan panel routing", () => { - it("defaults unknown query values to the schedule panel", () => { - expect(readMobilePlanPanel(null)).toBe("schedule"); - expect(readMobilePlanPanel("")).toBe("schedule"); - expect(readMobilePlanPanel("places")).toBe("schedule"); +describe("mobile plan routes", () => { + it("일정·지도·채팅 경로를 구분한다", () => { + expect(isPlanPathname("/plan")).toBe(true); + expect(isPlanPathname("/plan/room-1")).toBe(true); + expect(isPlanPathname("/map")).toBe(false); + expect(isMobileMapPathname("/map")).toBe(true); + expect(isChatPathname("/chat")).toBe(true); + expect(isChatPathname("/plan")).toBe(false); }); - it("accepts map and chat query values", () => { - expect(readMobilePlanPanel("map")).toBe("map"); - expect(readMobilePlanPanel("chat")).toBe("chat"); + it("일정 링크는 현재 방 일정 주소를 유지하고, 다른 화면에서는 /plan으로 간다", () => { + expect(mobileScheduleHref("/plan/room-1")).toBe("/plan/room-1"); + expect(mobileScheduleHref("/map")).toBe("/plan"); + expect(mobileScheduleHref("/bookmark")).toBe("/plan"); }); - it("orders mobile plan panels as chat, schedule, map", () => { - expect(MOBILE_PLAN_PANEL_ORDER).toEqual(["chat", "schedule", "map"]); - }); - - it("keeps the current plan room path when building panel links", () => { - expect(buildMobilePlanPanelHref("/plan/room-1", "schedule")).toBe( - "/plan/room-1", - ); - expect(buildMobilePlanPanelHref("/plan/room-1", "map")).toBe( - "/plan/room-1?view=map", - ); - expect(buildMobilePlanPanelHref("/plan/room-1", "chat")).toBe( - "/plan/room-1?view=chat", - ); - }); - - it("falls back to /plan outside plan routes", () => { - expect(buildMobilePlanPanelHref("/bookmark", "map")).toBe( - "/plan?view=map", - ); + it("예전 ?view= 값은 새 경로로 옮기고, 그 밖의 값은 무시한다", () => { + expect(legacyPlanViewPath("map")).toBe("/map"); + expect(legacyPlanViewPath("chat")).toBe("/chat"); + expect(legacyPlanViewPath("schedule")).toBeNull(); + expect(legacyPlanViewPath(null)).toBeNull(); }); }); From 97dd870de623c6c5fe5faf38d58b2b0e004eba0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Fri, 9 Oct 2026 16:00:27 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat(routes):=20=EC=A7=80=EB=8F=84=C2=B7?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=ED=99=94=EB=A9=B4=EC=9D=84=20/map,=20/cha?= =?UTF-8?q?t=20=EA=B2=BD=EB=A1=9C=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/chat/page.tsx | 20 ++++++ src/app/(main)/map/page.tsx | 20 ++++++ src/components/chat/ChatPanel.inline.test.tsx | 4 +- src/components/chat/ChatPanel.tsx | 23 +------ src/components/chat/chat-animations.ts | 19 +++--- src/components/layout/HeaderBar.tsx | 11 ++- src/components/layout/MainLayoutChrome.tsx | 62 ++++++++--------- src/components/layout/SideBar.tsx | 7 +- .../layout/travel-navigation.test.tsx | 7 +- src/components/map/Map.tsx | 4 +- src/components/mobile/MobileMainTabs.tsx | 18 +++-- src/components/place/PlaceDetailPanel.tsx | 10 ++- src/hooks/useChat.ts | 43 +++++++++--- src/hooks/useChatPanelOpen.ts | 14 ++-- src/hooks/useOpenPlaceOnMap.ts | 6 +- src/lib/auth-session.ts | 2 + src/lib/layout/mainChromeLayoutWidth.ts | 67 ++----------------- src/lib/mobile-view/index.ts | 11 +-- src/lib/mobile-view/plan-panel.ts | 38 ++++++----- src/proxy.ts | 2 + src/stores/chat-panel-store.ts | 17 +++-- 21 files changed, 201 insertions(+), 204 deletions(-) create mode 100644 src/app/(main)/chat/page.tsx create mode 100644 src/app/(main)/map/page.tsx 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/page.tsx b/src/app/(main)/map/page.tsx new file mode 100644 index 00000000..6da52312 --- /dev/null +++ b/src/app/(main)/map/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +import { MapWithDetailPanel } from "@/components/map"; +import { useMobileView } from "@/contexts/MobileViewContext"; + +/** `/map` — 모바일 전용 지도 화면. PC는 지도가 레이아웃에 붙어 있어 일정으로 보낸다 */ +export default function MapPage() { + const router = useRouter(); + const { isMobileDevice } = useMobileView(); + + useEffect(() => { + if (!isMobileDevice) router.replace("/plan"); + }, [isMobileDevice, router]); + + if (!isMobileDevice) return null; + return ; +} diff --git a/src/components/chat/ChatPanel.inline.test.tsx b/src/components/chat/ChatPanel.inline.test.tsx index 8ddfb048..92a625e3 100644 --- a/src/components/chat/ChatPanel.inline.test.tsx +++ b/src/components/chat/ChatPanel.inline.test.tsx @@ -31,7 +31,9 @@ it("keeps messages and the working AI composer inside one inline panel", async ( const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); // `/chat`(최대화)에 북마크에서 들어온 상태 - useChatPanelStore.setState({ minimized: false, returnPath: "/bookmark", openedInApp: true }); + 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); 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/MainLayoutChrome.tsx b/src/components/layout/MainLayoutChrome.tsx index 904450ea..0a1b4653 100644 --- a/src/components/layout/MainLayoutChrome.tsx +++ b/src/components/layout/MainLayoutChrome.tsx @@ -1,13 +1,18 @@ "use client"; -import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { isPackingPath } from "@/lib/room-context-path"; import { useCurrentRoomId } from "@/hooks/use-room-id"; import { useChat } from "@/hooks/useChat"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useMobileView } from "@/contexts/MobileViewContext"; -import { buildMobilePlanPanelHref, readMobilePlanPanel } from "@/lib/mobile-view"; +import { + isChatPathname, + isMobileMapPathname, + isPlanPathname, + legacyPlanViewPath, +} from "@/lib/mobile-view"; import { ChatPanel } from "@/components/chat"; import { MapWithDetailPanel } from "@/components/map"; @@ -22,14 +27,13 @@ import SideBar from "./SideBar"; function getMobileBackLink( pathname: string, isMobileDevice: boolean, - showMobilePlanSurface: boolean, - mobilePlanPanel: string, ): { href: string; label: string } | undefined { - if (isMobileDevice && pathname.startsWith("/bookmark/")) { + if (!isMobileDevice) return undefined; + if (pathname.startsWith("/bookmark/")) { return { href: "/bookmark", label: "북마크 목록으로 돌아가기" }; } - if (showMobilePlanSurface && mobilePlanPanel === "map") { - return { href: buildMobilePlanPanelHref(pathname, "schedule"), label: "일정으로 돌아가기" }; + if (isMobileMapPathname(pathname)) { + return { href: "/plan", label: "일정으로 돌아가기" }; } return undefined; } @@ -37,13 +41,10 @@ function getMobileBackLink( export function MainLayoutChrome({ children }: { children: ReactNode }) { const { isMobileDevice } = useMobileView(); const pathname = usePathname(); + const router = useRouter(); const searchParams = useSearchParams(); - const { chatState, closeChat } = useChat(); - const previousRoute = useRef(pathname); - useLayoutEffect(() => { - 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/travel-navigation.test.tsx b/src/components/layout/travel-navigation.test.tsx index 46df9c70..2fd30706 100644 --- a/src/components/layout/travel-navigation.test.tsx +++ b/src/components/layout/travel-navigation.test.tsx @@ -8,6 +8,8 @@ const state = vi.hoisted(() => ({ pathname: "/plan/room", query: "", mobile: fal const history = vi.hoisted(() => [] as string[]); const push = vi.hoisted(() => (href: string) => { history.push(state.pathname); state.pathname = href.split("?")[0]; }); const back = vi.hoisted(() => () => { state.pathname = history.pop() ?? state.pathname; }); +// 브라우저 기록 개수도 가짜 기록에 맞춘다 — 처음 들어온 페이지 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 }) })); @@ -35,7 +37,7 @@ 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", replace: vi.fn() }); - useChatPanelStore.setState({ minimized: false, returnPath: "/plan", openedInApp: false }); + useChatPanelStore.setState({ minimized: false }); history.length = 0; HTMLElement.prototype.scrollTo = vi.fn(); host = document.createElement("div"); document.body.append(host); root = createRoot(host); @@ -102,8 +104,7 @@ it("minimizing goes back in history instead of stacking entries; direct /chat en await control("minimize"); expect(state.pathname).toBe("/bookmark"); expect(history).toEqual([]); - // 주소로 바로 들어온 `/chat` — 앞 기록이 없으므로 /plan으로 바꾼다 - useChatPanelStore.setState({ minimized: false, openedInApp: false, returnPath: "/plan" }); + // 새 탭에서 주소로 바로 들어온 `/chat` — 기록이 하나뿐이라 /plan으로 바꾼다 state.pathname = "/chat"; await render(); await control("close"); expect(state.replace).toHaveBeenLastCalledWith("/plan"); diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index 1affbf5e..1d1ddc58 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"; @@ -271,7 +272,8 @@ function MapSearchResultPins() { export default function Map() { const pathname = usePathname(); - const isPlanPage = isPlanPath(pathname ?? ""); + // 모바일 지도(`/map`)도 일정 화면의 지도라 일정에 있는 장소의 북마크 핀을 숨긴다 + const isPlanPage = isPlanPath(pathname ?? "") || isMobileMapPathname(pathname ?? ""); const planStopPlaceIds = usePlanItineraryStopNormalizedPlaceIds(isPlanPage); const { selectedPlace, setSelectedPlace } = useSelectedPlace(); 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 (
); } @@ -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/MobilePlanPlaceSheets.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets.tsx new file mode 100644 index 00000000..5f3ecfc4 --- /dev/null +++ b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets.tsx @@ -0,0 +1,211 @@ +"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 ( + + {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 }; +} From 705ff89c2c95576111a757017c1acccd05a62cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Sat, 10 Oct 2026 11:32:06 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor(ui):=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EB=8B=AB=EA=B8=B0=20=EB=B2=84=ED=8A=BC=C2=B7=EC=A0=90=EC=84=A0?= =?UTF-8?q?=20=EB=B0=95=EC=8A=A4=C2=B7=EC=9E=A5=EC=86=8C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=83=AD=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/map/MapCloseButton.tsx | 24 ++++++++ src/components/map/MapDiscoverToolbar.tsx | 11 +--- .../mobile/DashedPlaceholderBox.tsx | 47 ++++++++++++++ src/components/place/PlaceDetailPanel.tsx | 61 ++++++------------- src/components/place/PlaceDetailTabs.tsx | 43 +++++++++++++ 5 files changed, 134 insertions(+), 52 deletions(-) create mode 100644 src/components/map/MapCloseButton.tsx create mode 100644 src/components/mobile/DashedPlaceholderBox.tsx create mode 100644 src/components/place/PlaceDetailTabs.tsx 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/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/place/PlaceDetailPanel.tsx b/src/components/place/PlaceDetailPanel.tsx index 3666e32f..d32cc545 100644 --- a/src/components/place/PlaceDetailPanel.tsx +++ b/src/components/place/PlaceDetailPanel.tsx @@ -17,15 +17,13 @@ import { import type { SearchResultCardProps } from "./SearchResultCard"; import { AddToBookmarkModal } from "./AddToBookmarkModal"; import { AddToScheduleModal } from "./AddToScheduleModal"; -import { TABS, type Tab } from "./types"; import { usePlaceDetailData } from "./usePlaceDetailData"; import { HeroSkeleton, HeroImage } from "./HeroSection"; import { PlaceDetailSkeleton, PlaceSheetSummarySkeleton } from "./PlaceDetailSkeleton"; import { PlaceDetailSheet } from "./PlaceDetailSheet"; import { PlaceSheetSummary } from "./PlaceSheetSummary"; import { PlaceSummaryHeader } from "./PlaceSummaryHeader"; -import { HomeTab } from "./HomeTab"; -import { ReviewsTab } from "./ReviewsTab"; +import { PlaceDetailTabs } from "./PlaceDetailTabs"; type PlaceDetailPanelProps = SearchResultCardProps & { onClose: () => void; @@ -60,7 +58,6 @@ export function PlaceDetailPanel({ onBack = onClose, layout = "panel", }: PlaceDetailPanelProps) { - const [activeTab, setActiveTab] = useState("홈"); const [bookmarkModalOpen, setBookmarkModalOpen] = useState(false); const [scheduleModalOpen, setScheduleModalOpen] = useState(false); @@ -190,45 +187,23 @@ export function PlaceDetailPanel({ : undefined; const tabs = ( - <> - {/* Tab navigation */} -
- {TABS.map((tab) => ( - - ))} -
- - {/* Tab content */} - {activeTab === "홈" && ( - - )} - {activeTab === "리뷰" && ( - - )} - + ); const modals = ( diff --git a/src/components/place/PlaceDetailTabs.tsx b/src/components/place/PlaceDetailTabs.tsx new file mode 100644 index 00000000..3c5c8fd6 --- /dev/null +++ b/src/components/place/PlaceDetailTabs.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState, type ComponentProps } from "react"; + +import { HomeTab } from "./HomeTab"; +import { ReviewsTab } from "./ReviewsTab"; +import { TABS, type Tab } from "./types"; + +/** 장소 상세의 홈·리뷰 탭 — 장소 상세 패널·시트와 지도 경로 보기 카드가 함께 쓴다 */ +export function PlaceDetailTabs({ + home, + reviews, +}: Readonly<{ + home: ComponentProps; + reviews: ComponentProps; +}>) { + const [activeTab, setActiveTab] = useState("홈"); + + return ( + <> + {/* Tab navigation */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Tab content */} + {activeTab === "홈" && } + {activeTab === "리뷰" && } + + ); +} From 6b8defb88fe3c3f888b964097fbdaf3c6c7e3aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Sat, 10 Oct 2026 12:33:55 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat(map):=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=A7=80=EB=8F=84=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EC=99=80=20=EC=A7=84=EC=9E=85=20=EC=A3=BC=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Design.md | 9 + src/assets/icons/index.tsx | 3 + src/assets/icons/route.svg | 5 + src/components/map/Map.tsx | 272 ++++++++++++++++-- src/components/map/MapWithDetailPanel.tsx | 18 +- src/components/map/PlanItineraryMapRoutes.tsx | 74 ++++- .../map/PlanItineraryStopMapPin.tsx | 41 +-- src/components/map/index.ts | 1 + src/lib/mobile-view/index.ts | 2 + src/lib/mobile-view/plan-panel.test.ts | 18 ++ src/lib/mobile-view/plan-panel.ts | 26 ++ 11 files changed, 401 insertions(+), 68 deletions(-) create mode 100644 src/assets/icons/route.svg 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/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/map/Map.tsx b/src/components/map/Map.tsx index 1d1ddc58..298b67b5 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -39,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"; @@ -49,9 +50,161 @@ import { import { MapSearchHereButton } from "./MapSearchHereButton"; import { MapDiscoverToolbar } from "./MapDiscoverToolbar"; 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; +}; + +/** 접힌 카드·일차 탭·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 }; +} + +function RouteViewCameraController({ + camera, + locations, + focusedItemId, + expanded, + candidate, +}: Pick) { + const map = useMap(); + const appliedRef = useRef(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 key = + camera.kind === "fit" + ? `fit:${camera.seq}:${expanded}:${locationsKey}` + : `${camera.kind}:${camera.seq}:${expanded}`; + if (appliedRef.current === key) return; + const target = locations.find((l) => l.itemId === focusedItemId); + // 고른 장소의 위치가 아직 없으면 채워질 때 다시 시도한다 + if (camera.kind === "place" && !target) return; + appliedRef.current = 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; +} + // ─── 장소 선택 시 지도 이동 ─────────────────────────────────────────────────── function SelectedPlaceController() { @@ -270,7 +423,15 @@ function MapSearchResultPins() { // ─── Component ─────────────────────────────────────────────────────────────── -export default function Map() { +export default function Map({ + routeView, + onOpenRouteView, +}: { + /** 있으면 경로 보기 — 검색·필터, 북마크·탐색 핀, 다른 일차 경로를 숨긴다 */ + routeView?: MapRouteView; + /** 있으면 지도 왼쪽 아래에 경로 보기 버튼을 둔다(모바일 지도) */ + onOpenRouteView?: () => void; +} = {}) { const pathname = usePathname(); // 모바일 지도(`/map`)도 일정 화면의 지도라 일정에 있는 장소의 북마크 핀을 숨긴다 const isPlanPage = isPlanPath(pathname ?? "") || isMobileMapPathname(pathname ?? ""); @@ -382,6 +543,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; @@ -424,7 +595,7 @@ export default function Map() { streetViewControl={false} mapTypeControl={false} fullscreenControl={false} - clickableIcons={selectedCategoryId == null} + clickableIcons={routeView ? true : selectedCategoryId == null} onClick={handleMapClick} onCameraChanged={handleCameraChanged} > @@ -441,25 +612,54 @@ export default function Map() { destination={destination} /> - - - - - + {routeView ? ( + <> + + + {routeView.candidate?.location ? ( + { + e.stop(); + routeView.onCandidateClick(); + }} + > + + + + + ) : null} + + ) : ( + <> + + + + + - + + + )} - {selectedPlace?.location && ( + {!routeView && selectedPlace?.location && ( e.stop()} @@ -490,19 +690,31 @@ export default function Map() { )} - + {routeView ? null : ( + + )} - {!bootstrap.ready ? null : ( + {!bootstrap.ready || routeView ? null : ( <> -
+
+ {onOpenRouteView ? ( + + ) : null} +
+
+ + + + ); + + if (!expanded) { + return ( + // 다른 장소 카드와 같은 배경에 점선 테두리만 달리해 아직 넣지 않은 자리임을 보인다 +
+ + {card} +
+ ); + } + + return ( + + {card} + + + ); +} 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..6471ef0a --- /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..8421b063 --- /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.tsx b/src/app/(main)/map/_components/useMobileRouteView.tsx new file mode 100644 index 00000000..22b52545 --- /dev/null +++ b/src/app/(main)/map/_components/useMobileRouteView.tsx @@ -0,0 +1,533 @@ +"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 = [...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); + } + + 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); + + 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, schedules.length)); + } else { + const byKey = entries.findIndex((e) => e.key === focus.key); + focusedIndex = byKey >= 0 ? byKey : Math.min(focus.index, entries.length - 1); + } + 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 && ghost.scheduleId === activeScheduleId ? ghost : undefined; + const candidateLocation = candidateDetail?.location ?? candidate?.location ?? null; + + const mapRouteView: MapRouteView | undefined = active + ? { + 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(); + }, + } + : undefined; + + 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: "" }); + + // 경로 보기가 아닐 때(일반 지도)는 카드를 만들지 않는다 — 장소마다 비용 요약까지 계산하므로 + let cards: ReactNode = null; + if (!active) { + cards = null; + } else if (schedulesQuery.isLoading || (schedules.length > 0 && entries.length === 0)) { + cards = ; + } else if (schedulesQuery.isError) { + cards = ( + + + + ); + } else if (entries.length === 0) { + cards = ( + + + + ); + } else { + cards = ( + focusEntry(index)} + onPreviewChange={setPreviewIndex} + cards={entries.map((entry, index) => ({ + key: entry.key, + node: + entry.kind === "empty" ? ( + + ) : entry.kind === "candidate" ? ( + { + if (index !== focusedIndex) focusEntry(index); + setExpandedAndRecenter(true); + }} + onCollapse={() => setExpandedAndRecenter(false)} + detailActive={index === focusedIndex} + /> + ) : ( + { + 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} + /> + ), + }))} + /> + ); + } + + const sheetSchedule = routeSheet ? schedules.find((s) => s.scheduleId === routeSheet.scheduleId) : undefined; + const overlay = active ? ( + <> +
+ {/* 펼치면 X·일차 탭은 사라지고 카드가 그 자리까지 커진다 */} +
+
+ +
+ +
+ {/* 접힘: 가장 긴 카드에 맞춰 모든 카드가 같은 높이 / 펼침: 지도의 70% */} +
+ {cards} +
+
+ + {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, overlay, open }; +} + +/** 카드가 하나뿐일 때도 넘기는 카드와 같은 폭·위치에 둔다 */ +function RouteCardSlot({ children }: Readonly<{ children: ReactNode }>) { + return
{children}
; +} diff --git a/src/app/(main)/map/page.tsx b/src/app/(main)/map/page.tsx index 6da52312..67082b29 100644 --- a/src/app/(main)/map/page.tsx +++ b/src/app/(main)/map/page.tsx @@ -1,20 +1,40 @@ "use client"; import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { MapWithDetailPanel } from "@/components/map"; import { useMobileView } from "@/contexts/MobileViewContext"; +import { readMapRouteParams } from "@/lib/mobile-view"; -/** `/map` — 모바일 전용 지도 화면. PC는 지도가 레이아웃에 붙어 있어 일정으로 보낸다 */ +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 ; + return ( + + {routeView.overlay} + + ); } 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..d3141fe1 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,37 @@ 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 addTime = !timeRange && Boolean(addWhenEmpty?.time); + const addExpense = !expenseSummary && Boolean(addWhenEmpty?.expense); + const addMemo = !memo && Boolean(addWhenEmpty?.memo); const meta = [place.primaryTypeDisplayName, place.subtitle].filter(Boolean).join(" · "); return ( -
+
- + ) : addTime ? ( + ) : null} {expenseSummary ? ( + ) : addExpense ? ( + ) : null}
) : null} @@ -179,6 +233,25 @@ export function MobilePlanPlaceCard({ />
+ ) : addMemo ? ( + // 메모가 있을 때와 같은 줄(구분선·아이콘·꺾쇠)을 흐리게 — 누르면 메모 시트 +
+ +
) : null} From 568c56fa9d15a3eff86a442dea4d6bdb4735daa8 Mon Sep 17 00:00:00 2001 From: minbros Date: Sat, 10 Oct 2026 14:52:50 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix(map):=20Sonar=20=EC=9D=B4=EC=8A=88?= =?UTF-8?q?=EC=99=80=20=EA=B2=BD=EB=A1=9C=C2=B7=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카드·지도 표시 책임 분리와 접근성 마크업 정리 - URL 동기화와 지연 좌표 처리에서 지도 카메라 상태 보존 - 직접 진입한 채팅은 안전한 앱 화면으로 복귀 --- .../_components/MobileRouteCandidateCard.tsx | 61 ++-- .../map/_components/MobileRoutePlaceCard.tsx | 8 +- .../_components/MobileRoutePlaceDetail.tsx | 4 +- .../_components/useMobileRouteView.test.tsx | 85 +++++ .../map/_components/useMobileRouteView.tsx | 170 ++++++---- .../mobile/MobilePlanPlaceCard.tsx | 92 +++--- .../mobile/MobilePlanPlaceSheets.tsx | 17 + src/components/chat/ChatPanel.inline.test.tsx | 10 +- .../layout/travel-navigation.test.tsx | 24 +- src/components/map/Map.tsx | 291 +++++------------- src/components/map/PlanItineraryMapRoutes.tsx | 16 +- .../map/RouteViewCameraController.test.tsx | 42 +++ .../map/RouteViewCameraController.tsx | 130 ++++++++ src/contexts/MainChromeLayoutWidthContext.tsx | 4 +- src/hooks/useChat.ts | 34 +- src/lib/layout/mainChromeLayoutWidth.test.ts | 9 +- src/lib/layout/mainChromeLayoutWidth.ts | 4 +- 17 files changed, 630 insertions(+), 371 deletions(-) create mode 100644 src/app/(main)/map/_components/useMobileRouteView.test.tsx create mode 100644 src/components/map/RouteViewCameraController.test.tsx create mode 100644 src/components/map/RouteViewCameraController.tsx diff --git a/src/app/(main)/map/_components/MobileRouteCandidateCard.tsx b/src/app/(main)/map/_components/MobileRouteCandidateCard.tsx index 17b96beb..02418ec1 100644 --- a/src/app/(main)/map/_components/MobileRouteCandidateCard.tsx +++ b/src/app/(main)/map/_components/MobileRouteCandidateCard.tsx @@ -73,43 +73,20 @@ export function MobileRouteCandidateCard({
{isLoading && !detail ? ( -
-
+ ) : ( <>

{name}

-

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

+ )}
@@ -162,3 +139,35 @@ export function MobileRouteCandidateCard({ ); } + +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/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx index 6471ef0a..9ea90a49 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx @@ -158,14 +158,14 @@ export function MobileRouteExpandedShell({ /** 처음 불러오는 동안 — 카드와 같은 자리를 잡는다 */ export function MobileRoutePlaceCardSkeleton() { return ( -
+ -
+ -
-
+ + ); } diff --git a/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx index 8421b063..af8e2abc 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx @@ -49,10 +49,10 @@ export function MobileRoutePlaceDetail({
{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..c1e0d8af --- /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 동기화 후에도 $camera 카메라를 유지한다", 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(camera); + } 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 index 22b52545..7e0fb0b3 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.tsx @@ -161,37 +161,14 @@ export function useMobileRouteView({ }); }); - 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); - } + const { entries, anchorIndex, ghost } = insertCandidate(baseEntries, candidate); - const [focus, setFocus] = useState({ key: null, index: 0, camera: { kind: "fit", seq: 0 } }); + const [focus, setFocus] = useRouteFocus(active, initialItemId); const [expanded, setExpanded] = useState(false); /** 카드의 ⋮·시간·메모에서 연 시트 — 일정 목록과 같은 시트를 쓴다 */ const [routeSheet, setRouteSheet] = useState<{ scheduleId: number; sheet: MobilePlanPlaceSheet } | null>(null); - 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, schedules.length)); - } else { - const byKey = entries.findIndex((e) => e.key === focus.key); - focusedIndex = byKey >= 0 ? byKey : Math.min(focus.index, entries.length - 1); - } + 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; @@ -297,11 +274,12 @@ export function useMobileRouteView({ } // 후보 핀은 그 일차를 볼 때만 그린다 - const ghostOnMap = ghost && ghost.scheduleId === activeScheduleId ? ghost : undefined; + const ghostOnMap = ghost?.scheduleId === activeScheduleId ? ghost : undefined; const candidateLocation = candidateDetail?.location ?? candidate?.location ?? null; - const mapRouteView: MapRouteView | undefined = active - ? { + function buildRouteView(): MapRouteView | undefined { + if (!active) return undefined; + return { scheduleId: activeScheduleId, color: activeScheduleId === null ? FALLBACK_DAY_COLOR : dayColor(activeScheduleId), focusedItemId, @@ -315,7 +293,7 @@ export function useMobileRouteView({ if (index >= 0) focusEntry(index, "pan"); }, // 처음 들어올 때 — 일정 카드로 들어왔으면 그 장소를 가까이, 경로 보기 버튼이면 일차 전체를 보여 준다 - camera: focus.key === null ? { kind: initialItemId !== null ? "place" : "fit", seq: 0 } : focus.camera, + camera: focus.camera, locations, expanded: isExpanded, candidate: @@ -353,8 +331,8 @@ export function useMobileRouteView({ onBackgroundClick: () => { if (candidate) closeCandidate(); }, - } - : undefined; + }; + } const goToDay = (dayNumber: number) => { const index = entries.findIndex((e) => e.dayNumber === dayNumber); @@ -394,36 +372,12 @@ export function useMobileRouteView({ const itemExpenses = (scheduleId: number, itemId: number) => expensesInScope(expenses.list.data ?? [], { scheduleId, scheduleItemId: itemId, label: "" }); - // 경로 보기가 아닐 때(일반 지도)는 카드를 만들지 않는다 — 장소마다 비용 요약까지 계산하므로 - let cards: ReactNode = null; - if (!active) { - cards = null; - } else if (schedulesQuery.isLoading || (schedules.length > 0 && entries.length === 0)) { - cards = ; - } else if (schedulesQuery.isError) { - cards = ( - - - - ); - } else if (entries.length === 0) { - cards = ( - - - - ); - } else { - cards = ( - focusEntry(index)} - onPreviewChange={setPreviewIndex} - cards={entries.map((entry, index) => ({ - key: entry.key, - node: - entry.kind === "empty" ? ( + function renderEntry(entry: StripEntry, index: number): ReactNode { + if (entry.kind === "empty") return ( - ) : entry.kind === "candidate" ? ( + + ); + if (entry.kind === "candidate") return ( setExpandedAndRecenter(false)} detailActive={index === focusedIndex} /> - ) : ( + + ); + return ( 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 ? ( <> @@ -500,7 +488,7 @@ export function useMobileRouteView({
{/* 접힘: 가장 긴 카드에 맞춰 모든 카드가 같은 높이 / 펼침: 지도의 70% */}
- {cards} + {renderCards()}
@@ -524,10 +512,62 @@ export function useMobileRouteView({ ) : null; - return { mapRouteView, overlay, open }; + 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; +} + +function initialFocus(itemId: number | null): Focus { + return { key: null, index: 0, camera: { kind: itemId === null ? "fit" : "place", seq: 0 } }; +} + +function useRouteFocus(active: boolean, initialItemId: number | null) { + const [focus, setFocus] = useState(() => initialFocus(initialItemId)); + const [wasActive, setWasActive] = useState(active); + // URL 동기화로 item이 바뀌어도 진입 시 카메라 모드는 유지한다. + // 같은 지도에서 경로 보기를 다시 열 때만 새 진입 위치를 적용한다. + if (wasActive !== active) { + setWasActive(active); + if (active) setFocus(initialFocus(initialItemId)); + } + return [focus, setFocus] as const; +} diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx index d3141fe1..ccf3e337 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx @@ -74,12 +74,6 @@ export function MobilePlanPlaceCard({ articleClassName, addWhenEmpty, }: MobilePlanPlaceCardProps) { - const [memoExpanded, setMemoExpanded] = useState(false); - const timeRange = formatScheduleTimeRange(place.startTime ?? "", place.endTime); - const memo = place.memo?.trim() ?? ""; - const addTime = !timeRange && Boolean(addWhenEmpty?.time); - const addExpense = !expenseSummary && Boolean(addWhenEmpty?.expense); - const addMemo = !memo && Boolean(addWhenEmpty?.memo); const meta = [place.primaryTypeDisplayName, place.subtitle].filter(Boolean).join(" · "); return ( @@ -111,6 +105,46 @@ export function MobilePlanPlaceCard({

{place.title}

{meta ?

{meta}

: null} + +
+ + + + + + + {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} - ) : addTime ? ( + ) : null} + {!timeRange && addTime ? ( - ) : addExpense ? ( + ) : null} + {!expenseSummary && addExpense ? (
) : 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 ? (
- ) : addMemo ? ( + ) : null} + {!memo && addMemo ? ( // 메모가 있을 때와 같은 줄(구분선·아이콘·꺾쇠)을 흐리게 — 누르면 메모 시트
) : null} - - - {editing ? ( - - ) : null} - - ); + ; } diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets.tsx index 5f3ecfc4..cefd0b7c 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets.tsx @@ -149,6 +149,23 @@ export function MobilePlanPlaceSheets({ 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 ? ( diff --git a/src/components/chat/ChatPanel.inline.test.tsx b/src/components/chat/ChatPanel.inline.test.tsx index 92a625e3..89f4f9f4 100644 --- a/src/components/chat/ChatPanel.inline.test.tsx +++ b/src/components/chat/ChatPanel.inline.test.tsx @@ -30,9 +30,9 @@ 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); - // `/chat`(최대화)에 북마크에서 들어온 상태 + // `/chat`(최대화) 주소로 직접 들어온 상태 useChatPanelStore.setState({ minimized: false }); - // 앱 안에서 연 채팅 — 돌아갈 기록이 있다 + // 다른 브라우저 기록이 있어도 앱에서 연 채팅이 아니므로 일정으로 돌아간다 Object.defineProperty(window.history, "length", { configurable: true, value: 2 }); try { await act(async () => root.render()); @@ -57,14 +57,16 @@ it("keeps messages and the working AI composer inside one inline panel", async ( expect(host.querySelectorAll("textarea")).toHaveLength(1); await act(async () => minimize!.click()); expect(useChatPanelStore.getState().minimized).toBe(true); - expect(boundary.back).toHaveBeenCalledTimes(1); - boundary.pathname = "/bookmark"; + 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().minimized).toBe(false); expect(boundary.back).not.toHaveBeenCalled(); diff --git a/src/components/layout/travel-navigation.test.tsx b/src/components/layout/travel-navigation.test.tsx index 2fd30706..c410c46f 100644 --- a/src/components/layout/travel-navigation.test.tsx +++ b/src/components/layout/travel-navigation.test.tsx @@ -6,8 +6,17 @@ 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", replace: (() => {}) as (href: string) => void })); // 실제 이동처럼 push·back이 경로와 기록을 바꾼다 — 다음 render()에서 반영된다 const history = vi.hoisted(() => [] as string[]); -const push = vi.hoisted(() => (href: string) => { history.push(state.pathname); state.pathname = href.split("?")[0]; }); -const back = vi.hoisted(() => () => { state.pathname = history.pop() ?? state.pathname; }); +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 }) })); @@ -39,6 +48,8 @@ beforeEach(() => { 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()); @@ -116,6 +127,15 @@ it("leaving /chat through a menu closes chat without a minimized panel", async ( 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(); diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index 298b67b5..6d224724 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -49,6 +49,7 @@ import { } from "./map-pin-stroke"; import { MapSearchHereButton } from "./MapSearchHereButton"; import { MapDiscoverToolbar } from "./MapDiscoverToolbar"; +import { RouteViewCameraController } from "./RouteViewCameraController"; import { MapDiscoverPlaces } from "./MapDiscoverPlaces"; import { PlanItineraryMapRoutes, type PlanItineraryRouteDay } from "./PlanItineraryMapRoutes"; import type { OpenValue, RatingValue } from "./map-filters"; @@ -80,131 +81,6 @@ export type MapRouteView = PlanItineraryRouteDay & { onBackgroundClick: () => void; }; -/** 접힌 카드·일차 탭·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 }; -} - -function RouteViewCameraController({ - camera, - locations, - focusedItemId, - expanded, - candidate, -}: Pick) { - const map = useMap(); - const appliedRef = useRef(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 key = - camera.kind === "fit" - ? `fit:${camera.seq}:${expanded}:${locationsKey}` - : `${camera.kind}:${camera.seq}:${expanded}`; - if (appliedRef.current === key) return; - const target = locations.find((l) => l.itemId === focusedItemId); - // 고른 장소의 위치가 아직 없으면 채워질 때 다시 시도한다 - if (camera.kind === "place" && !target) return; - appliedRef.current = 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; -} - // ─── 장소 선택 시 지도 이동 ─────────────────────────────────────────────────── function SelectedPlaceController() { @@ -423,7 +299,7 @@ function MapSearchResultPins() { // ─── Component ─────────────────────────────────────────────────────────────── -export default function Map({ +export default function TripMap({ routeView, onOpenRouteView, }: { @@ -437,7 +313,7 @@ export default function 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, @@ -450,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( @@ -595,7 +462,7 @@ export default function Map({ streetViewControl={false} mapTypeControl={false} fullscreenControl={false} - clickableIcons={routeView ? true : selectedCategoryId == null} + clickableIcons={Boolean(routeView) || selectedCategoryId == null} onClick={handleMapClick} onCameraChanged={handleCameraChanged} > @@ -612,80 +479,17 @@ export default function Map({ destination={destination} /> - {routeView ? ( - <> - - - {routeView.candidate?.location ? ( - { - e.stop(); - routeView.onCandidateClick(); - }} - > - - - - - ) : null} - - ) : ( + {routeView ? : ( <> - - - - - + + )} - {!routeView && selectedPlace?.location && ( - e.stop()} - > - - - - - )} + {!routeView && } )} @@ -739,3 +543,78 @@ export default function Map({ ); } + +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/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 58151c60..2d27d4d8 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -92,16 +92,12 @@ export function PlanItineraryMapRoutes({ const inRouteView = routeDay != null; const routesReady = routeDay?.routesReady ?? true; const visibleScheduleIds = useMemo( - () => - inRouteView - ? routeDayScheduleId !== null - ? [routeDayScheduleId] - : [] - : Object.keys(visibleByScheduleId) - .map((k) => Number(k)) - .filter( - (id) => Number.isFinite(id) && visibleByScheduleId[id] === true, - ), + () => { + if (inRouteView) return routeDayScheduleId !== null ? [routeDayScheduleId] : []; + return Object.keys(visibleByScheduleId) + .map(Number) + .filter((id) => Number.isFinite(id) && visibleByScheduleId[id] === true); + }, [inRouteView, routeDayScheduleId, visibleByScheduleId], ); 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/contexts/MainChromeLayoutWidthContext.tsx b/src/contexts/MainChromeLayoutWidthContext.tsx index 0d0e6196..7f6f381f 100644 --- a/src/contexts/MainChromeLayoutWidthContext.tsx +++ b/src/contexts/MainChromeLayoutWidthContext.tsx @@ -8,7 +8,7 @@ import { useSectionWidth } from "@/contexts/SectionWidthContext"; import { MAIN_LAYOUT_WIDTH_TRANSITION, resolveLeftSectionAnimateMaxWidth, - resolveLeftSectionMinWidthPx, + resolveDesktopLeftSectionMinWidthPx, resolveLeftSectionTargetMaxWidthPx, } from "@/lib/layout/mainChromeLayoutWidth"; @@ -46,7 +46,7 @@ export function MainChromeLayoutWidthProvider({ targetMaxWidthPx, isMobile: isMobileDevice, }), - leftSectionAnimateMinWidth: resolveLeftSectionMinWidthPx(isMobileDevice, pathname), + leftSectionAnimateMinWidth: isMobileDevice ? 0 : resolveDesktopLeftSectionMinWidthPx(pathname), layoutTransition: MAIN_LAYOUT_WIDTH_TRANSITION, }), [targetMaxWidthPx, isMobileDevice, pathname], diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts index 9d2000a5..c1464fd1 100644 --- a/src/hooks/useChat.ts +++ b/src/hooks/useChat.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; import { isChatPathname, CHAT_PATH } from "@/lib/mobile-view"; @@ -11,6 +11,10 @@ export type { ChatState }; /** 돌아갈 기록이 없을 때(새 탭에서 `/chat`을 바로 연 경우) 갈 곳 */ const CHAT_FALLBACK_PATH = "/plan"; +const CHAT_RETURN_HISTORY_KEY = "uttaeChatReturn"; +// openChat의 push가 완료됐을 때만 현재 history entry에 복귀 가능 표시를 남긴다. +// 훅은 사이드바·패널 등에서 함께 쓰므로 이동 요청은 모듈에서 공유한다. +let pendingChatOrigin: string | null = null; /** * 채팅 상태와 이동 — `/chat`이면 최대화, 아니면 store의 최소화 여부로 판단한다. @@ -22,19 +26,37 @@ export function useChat() { const router = useRouter(); const minimized = useChatPanelStore((s) => s.minimized); const onChatRoute = isChatPathname(pathname); - const chatState: ChatState = onChatRoute ? "maximized" : minimized ? "minimized" : "closed"; + let chatState: ChatState = minimized ? "minimized" : "closed"; + if (onChatRoute) chatState = "maximized"; + + useEffect(() => { + if (pendingChatOrigin === null) return; + if (onChatRoute) { + window.history.replaceState( + { ...window.history.state, [CHAT_RETURN_HISTORY_KEY]: true }, + "", + ); + pendingChatOrigin = null; + } else if (pathname !== pendingChatOrigin) { + // 채팅 대신 다른 페이지로 이동했다면 이전 요청을 재사용하지 않는다. + pendingChatOrigin = null; + } + }, [onChatRoute, pathname]); const openChat = useCallback(() => { useChatPanelStore.getState().setMinimized(false); - if (!onChatRoute) router.push(CHAT_PATH); - }, [onChatRoute, router]); + if (!onChatRoute) { + pendingChatOrigin = pathname; + router.push(CHAT_PATH); + } + }, [onChatRoute, pathname, router]); const leaveChat = useCallback( (nextMinimized: boolean) => { useChatPanelStore.getState().setMinimized(nextMinimized); if (!onChatRoute) return; - // 새 탭에서 `/chat`을 바로 열면 기록이 하나뿐이라 back()이 아무 일도 하지 않는다 — 그때는 일정으로 바꾼다 - if (window.history.length > 1) router.back(); + // 기록 길이에는 외부 사이트·앞으로 갈 기록도 포함된다. 앱에서 연 entry만 뒤로 간다. + if (window.history.state?.[CHAT_RETURN_HISTORY_KEY] === true) router.back(); else router.replace(CHAT_FALLBACK_PATH); }, [onChatRoute, router], diff --git a/src/lib/layout/mainChromeLayoutWidth.test.ts b/src/lib/layout/mainChromeLayoutWidth.test.ts index 5be2625f..bf973823 100644 --- a/src/lib/layout/mainChromeLayoutWidth.test.ts +++ b/src/lib/layout/mainChromeLayoutWidth.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { resolveLeftSectionTargetMaxWidthPx, resolveLeftSectionMinWidthPx } from "./mainChromeLayoutWidth"; +import { resolveLeftSectionTargetMaxWidthPx, resolveDesktopLeftSectionMinWidthPx } from "./mainChromeLayoutWidth"; it("keeps the schedule width across collaboration tabs and /chat, including page width effects", () => { for (const pathname of ["/plan", "/plan/room", "/chat", "/search", "/bookmark", "/bookmark/folder", "/member-settings", "/room-settings", "/settings"]) { @@ -16,8 +16,7 @@ it("lets the ledger and packing use the full desktop workspace", () => { }); it("removes the desktop minimum width only for packing", () => { - expect(resolveLeftSectionMinWidthPx(false, "/packing/room")).toBe(0); - expect(resolveLeftSectionMinWidthPx(false, "/plan/room")).toBe(400); - expect(resolveLeftSectionMinWidthPx(false, "/chat")).toBe(400); - expect(resolveLeftSectionMinWidthPx(true, "/plan/room")).toBe(0); + expect(resolveDesktopLeftSectionMinWidthPx("/packing/room")).toBe(0); + expect(resolveDesktopLeftSectionMinWidthPx("/plan/room")).toBe(400); + expect(resolveDesktopLeftSectionMinWidthPx("/chat")).toBe(400); }); diff --git a/src/lib/layout/mainChromeLayoutWidth.ts b/src/lib/layout/mainChromeLayoutWidth.ts index 5dfce6b1..a8cfc1eb 100644 --- a/src/lib/layout/mainChromeLayoutWidth.ts +++ b/src/lib/layout/mainChromeLayoutWidth.ts @@ -108,6 +108,6 @@ export function resolveLeftSectionAnimateMaxWidth({ return "none"; } -export function resolveLeftSectionMinWidthPx(isMobile: boolean, pathname = ""): number | string { - return isMobile || isPackingPath(pathname) ? 0 : parseLayoutLengthToPx(CHAT_PANEL_DOCKED_WIDTH); +export function resolveDesktopLeftSectionMinWidthPx(pathname = ""): number { + return isPackingPath(pathname) ? 0 : parseLayoutLengthToPx(CHAT_PANEL_DOCKED_WIDTH); } From 187c849982573535459a51ee2dc885836e7e87b4 Mon Sep 17 00:00:00 2001 From: minbros Date: Sat, 10 Oct 2026 16:45:53 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix(map):=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=20=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=ED=99=95=EB=8C=80=20=EB=8F=99=EC=9E=91=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/useMobileRouteView.test.tsx | 4 ++-- .../map/_components/useMobileRouteView.tsx | 20 ++----------------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/src/app/(main)/map/_components/useMobileRouteView.test.tsx b/src/app/(main)/map/_components/useMobileRouteView.test.tsx index c1e0d8af..806922ff 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.test.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.test.tsx @@ -54,7 +54,7 @@ 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 동기화 후에도 $camera 카메라를 유지한다", async ({ url, camera, item }) => { +])("$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 } } }); @@ -76,7 +76,7 @@ it.each([ 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(camera); + expect(current?.mapRouteView?.camera.kind).toBe("place"); } finally { await act(async () => root.unmount()); queryClient.clear(); diff --git a/src/app/(main)/map/_components/useMobileRouteView.tsx b/src/app/(main)/map/_components/useMobileRouteView.tsx index 7e0fb0b3..1a0b967b 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.tsx @@ -163,7 +163,7 @@ export function useMobileRouteView({ const { entries, anchorIndex, ghost } = insertCandidate(baseEntries, candidate); - const [focus, setFocus] = useRouteFocus(active, initialItemId); + 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); @@ -293,7 +293,7 @@ export function useMobileRouteView({ if (index >= 0) focusEntry(index, "pan"); }, // 처음 들어올 때 — 일정 카드로 들어왔으면 그 장소를 가까이, 경로 보기 버튼이면 일차 전체를 보여 준다 - camera: focus.camera, + camera: focus.key === null ? { kind: initialItemId !== null ? "place" : "fit", seq: 0 } : focus.camera, locations, expanded: isExpanded, candidate: @@ -555,19 +555,3 @@ function resolveFocusedIndex(entries: StripEntry[], focus: Focus, initialItemId: return focusedIndex; } - -function initialFocus(itemId: number | null): Focus { - return { key: null, index: 0, camera: { kind: itemId === null ? "fit" : "place", seq: 0 } }; -} - -function useRouteFocus(active: boolean, initialItemId: number | null) { - const [focus, setFocus] = useState(() => initialFocus(initialItemId)); - const [wasActive, setWasActive] = useState(active); - // URL 동기화로 item이 바뀌어도 진입 시 카메라 모드는 유지한다. - // 같은 지도에서 경로 보기를 다시 열 때만 새 진입 위치를 적용한다. - if (wasActive !== active) { - setWasActive(active); - if (active) setFocus(initialFocus(initialItemId)); - } - return [focus, setFocus] as const; -}