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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 16 additions & 25 deletions src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ import {
import type { PlanPlace } from "@/lib/plan/types";
import { cn } from "@/lib/utils";
import { useSelectedPlace } from "@/contexts/SelectedPlaceContext";
import { usePlanMapFocusStore } from "@/stores/plan-map-focus-store";
import {
PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE,
planPlaceFocusTarget,
usePlanMapFocusStore,
} from "@/stores/plan-map-focus-store";

import { PlanItemMemoEditor, PlanItemMemoReadOnly } from "./PlanItemMemoForm";
import { PlanItemTimeEditor } from "./PlanItemTimeForm";
Expand Down Expand Up @@ -80,9 +84,10 @@ export function PlanPlaceCard({
s.focus.scheduleId === focusScheduleId &&
s.focus.itemId === place.itemId,
);
/** 지도 핀으로 고른 경우에만 값이 있다 — 그때 일정 목록을 이 카드로 스크롤한다 */
const focusSeqFromMap = usePlanMapFocusStore((s) =>
s.focus?.source === "map" &&
/** 카드가 아닌 곳(지도 핀·장소 이동 스테퍼)에서 고른 경우에만 값이 있다 — 그때 일정 목록을 이 카드로 스크롤한다 */
const focusSeqToReveal = usePlanMapFocusStore((s) =>
s.focus !== null &&
s.focus.source !== "card" &&
s.focus.roomId === focusRoomId &&
s.focus.scheduleId === focusScheduleId &&
s.focus.itemId === place.itemId
Expand All @@ -91,10 +96,10 @@ export function PlanPlaceCard({
);
const articleRef = useRef<HTMLElement>(null);
useEffect(() => {
if (focusSeqFromMap === null) return;
if (focusSeqToReveal === null) return;
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
articleRef.current?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" });
}, [focusSeqFromMap]);
}, [focusSeqToReveal]);
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const blockCardDragRef = useRef(false);
const [thumbnailRef, thumbnailInViewport] = useInViewport<HTMLElement>();
Expand Down Expand Up @@ -202,34 +207,20 @@ export function PlanPlaceCard({
setSelectedPlace(null);
return;
}
const loc = place.location;
const hasLocation =
loc != null &&
typeof loc.lat === "number" &&
typeof loc.lng === "number" &&
Number.isFinite(loc.lat) &&
Number.isFinite(loc.lng);
const target = planPlaceFocusTarget(place, focusRoomId, focusScheduleId, "card");
if (!target) return;
// 일정에 넣은 장소는 상세를 보려는 게 아니라 위치를 보려는 것 — 상세(조회 포함) 대신 카드·지도 핀만 강조한다
setSelectedPlace(null);
focusPlace({
roomId: focusRoomId,
scheduleId: focusScheduleId,
itemId: place.itemId,
googlePlaceId: place.googlePlaceId?.trim() || null,
location: hasLocation ? { lat: loc.lat, lng: loc.lng } : null,
source: "card",
});
if (!hasLocation) toast.info("지도에 표시할 위치 정보가 없어요.");
focusPlace(target);
if (!target.location) toast.info(PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE);
},
[
clearFocus,
focusPlace,
focusRoomId,
focusScheduleId,
focused,
place.googlePlaceId,
place.itemId,
place.location,
place,
setSelectedPlace,
],
);
Expand Down
5 changes: 5 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
overflow: auto !important; /* NOSONAR: S9423 — Google Maps의 인라인 overflow:scroll에 의한 불필요한 스크롤바를 제거한다. */
}

/* PC 지도 버튼은 지도 끝에서 아래·좌우 모두 24px — 확대·축소(+/−)의 아래는 저작권 줄과 기본 여백으로 이미 24px이라 오른쪽만 넓힌다. */
[data-map-controls-inset] .gm-bundled-control {
margin-right: 24px !important; /* NOSONAR: S9423 — Google Maps가 주입하는 인라인 margin(10px)을 재정의한다. */
}

@layer base {
html {
@apply font-sans;
Expand Down
32 changes: 28 additions & 4 deletions src/components/map/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
readRoomMapViewport,
writeRoomMapViewport,
} from "@/lib/map-room-viewport-storage";
import { MAP_DETAIL_PANEL_COVER_WIDTH_PX } from "@/lib/layout-tokens";
import { isPlanPath } from "@/lib/layout/mainChromeLayoutWidth";
import { isMobileMapPathname } from "@/lib/mobile-view";
import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius";
Expand Down Expand Up @@ -62,6 +63,7 @@ import { MapDiscoverToolbar } from "./MapDiscoverToolbar";
import { RouteViewCameraController } from "./RouteViewCameraController";
import { MapDiscoverPlaces } from "./MapDiscoverPlaces";
import { PlanItineraryMapRoutes, type PlanItineraryRouteDay } from "./PlanItineraryMapRoutes";
import { PlanRouteStepper } from "./PlanRouteStepper";
import type { OpenValue, RatingValue } from "./map-filters";

/** 모바일 경로 보기 — 고른 일차만 그리고, 카드 선택에 맞춰 지도를 옮긴다 */
Expand Down Expand Up @@ -132,6 +134,9 @@ function SelectedPlaceController() {

const VIEWPORT_PERSIST_DEBOUNCE_MS = 400;

/** 오른쪽 아래 확대·축소(+/−) 자리 — 지도 끝 24 + 버튼 40 + 간격 12. 장소 이동 스테퍼가 이보다 오른쪽으로 가지 않는다 */
const ZOOM_CONTROL_CLEARANCE_PX = 24 + 40 + 12;

/** bootstrap 복원 직후 Zustand `mapCenter`·줌을 검색/일정 UI와 맞춤 */
function MapBootstrapSync({
ready,
Expand Down Expand Up @@ -402,7 +407,7 @@ export default function TripMap({
const isPlanPage = isPlanPath(pathname ?? "") || isMobileMapPathname(pathname ?? "");
const planStopPlaceIds = usePlanItineraryStopNormalizedPlaceIds(isPlanPage);

const { setSelectedPlace } = useSelectedPlace();
const { selectedPlace, setSelectedPlace } = useSelectedPlace();
const { isMobileDevice } = useMobileView();
const clearPlanFocus = usePlanMapFocusStore((s) => s.clearFocus);
const setMapCamera = useMapCenterStore((s) => s.setMapCamera);
Expand Down Expand Up @@ -557,7 +562,7 @@ export default function TripMap({
);

return (
<div className="relative h-full min-h-0 w-full min-w-0">
<div className="relative h-full min-h-0 w-full min-w-0" data-map-controls-inset={isMobileDevice ? undefined : ""}>
<div className="absolute inset-0 min-h-0 overflow-hidden">
{!bootstrap.ready ? (
<div
Expand Down Expand Up @@ -612,10 +617,11 @@ export default function TripMap({

{!routeView && <SelectedPlaceMarker />}

{/* PC는 확대·축소(+/−) 바로 위 — 같은 오른쪽 아래 컨트롤 자리에 넣으면 지도가 +/− 위로 쌓아 준다 */}
{/* PC는 확대·축소(+/−) 바로 위 — 같은 오른쪽 아래 컨트롤 자리에 넣으면 지도가 +/− 위로 쌓아 준다.
오른쪽은 +/−와 같이 지도 끝에서 24px(globals.css `[data-map-controls-inset]`) */}
{routeView || isMobileDevice ? null : (
<MapControl position={ControlPosition.RIGHT_BOTTOM}>
<div className="mr-2.5">{bookmarkToggle}</div>
<div className="mr-6">{bookmarkToggle}</div>
</MapControl>
)}
</GoogleMap>
Expand All @@ -633,6 +639,24 @@ export default function TripMap({
/>
)}

{/* 웹 동선보기 장소 이동 — 지도 아래 가운데, 다른 지도 버튼처럼 아래 24px.
상세 패널이 열리면 패널 오른쪽 끝~지도 오른쪽 끝의 가운데로 옮긴다.
오른쪽 칸은 +/− 자리만큼 줄어들지 않아, 지도가 좁아지면 +/−에 밀려 왼쪽으로 넘치고(justify-end)
z-[16]이 상세 패널(`MapWithDetailPanel` z-20)보다 낮아 패널 뒤로 가려진다 */}
{bootstrap.ready && !routeView && !isMobileDevice && isPlanPage ? (
<div
style={{
paddingLeft: selectedPlace ? MAP_DETAIL_PANEL_COVER_WIDTH_PX : 0,
gridTemplateColumns: `minmax(0, 1fr) auto minmax(${ZOOM_CONTROL_CLEARANCE_PX}px, 1fr)`,
}}
className="pointer-events-none absolute inset-x-0 bottom-6 z-[16] grid justify-end transition-[padding] duration-300 ease-out"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 재검색 버튼과 스테퍼의 위치를 분리해 주세요

카테고리 검색을 완료한 뒤 검색 중심에서 충분히 떨어진 일정 카드를 선택하면 두 버튼이 함께 표시됩니다. PlanPlaceCard의 setSelectedPlace(null); focusPlace(target);는 상세만 닫고 카테고리와 discoverSnapshot을 유지합니다. 이후 지도 중심이 이동하면 usePlacesSearchHereVisible의 재검색 조건이 성립합니다.

이때 스테퍼는 하단 중앙의 bottom-6 z-[16]에 놓이는데, SearchHereFloatingButton.tsx:17도 같은 하단 중앙에 bottom-6 left-1/2 z-[17]로 표시됩니다. 더 높은 재검색 버튼이 순서 표시와 겹치는 조작 영역을 가려, 장소 이동을 누르려다 검색이 실행될 수 있습니다. 두 기능이 함께 활성화되어도 표시·클릭 영역이 겹치지 않도록 배치해 주세요.

>
<div className="col-start-2">
<PlanRouteStepper roomId={currentRoomId} />
</div>
</div>
) : null}

{!bootstrap.ready || routeView ? null : (
<>
<MapSearchHereButton discoverCategoryId={selectedCategoryId} />
Expand Down
87 changes: 87 additions & 0 deletions src/components/map/PlanRouteStepper.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";

import type { PlanPlace } from "@/lib/plan/types";
import { scheduleItemsQueryKey } from "@/lib/query-keys";
import { PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE, usePlanMapFocusStore } from "@/stores/plan-map-focus-store";

import { PlanRouteStepper } from "./PlanRouteStepper";

const state = vi.hoisted(() => ({ select: vi.fn(), toast: vi.fn() }));
vi.mock("@/hooks/useRooms", () => ({ useRoomSchedules: () => ({ isSuccess: true }) }));
vi.mock("@/contexts/SelectedPlaceContext", () => ({ useSelectedPlace: () => ({ setSelectedPlace: state.select }) }));
vi.mock("sonner", () => ({ toast: { info: state.toast } }));

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const places: PlanPlace[] = [
{ id: "a", title: "A", itemId: 1, googlePlaceId: "ga", location: { lat: 1, lng: 1 } },
{ id: "b", title: "B", itemId: 2, googlePlaceId: "gb", location: { lat: 2, lng: 2 } },
{ id: "c", title: "C", itemId: 3, googlePlaceId: "gc" },
];
let host: HTMLDivElement;
let root: Root;
const group = () => host.querySelector<HTMLElement>('[role="group"][aria-label="장소 이동"]');
const button = (label: string) => group()!.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!;
const position = () => group()!.querySelector("p > span[aria-hidden]")!.textContent;
function focusItem(itemId: number) {
usePlanMapFocusStore.setState({
focus: { roomId: "room", scheduleId: 10, itemId, googlePlaceId: null, location: null, source: "card", seq: 1 },
});
}

beforeEach(async () => {
state.select.mockReset();
state.toast.mockReset();
const queryClient = new QueryClient();
queryClient.setQueryData(scheduleItemsQueryKey("room", 10), places);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
usePlanMapFocusStore.setState({ focus: null });
await act(async () =>
root.render(
<QueryClientProvider client={queryClient}>
<PlanRouteStepper roomId="room" />
</QueryClientProvider>,
),
);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});

it("is hidden until a place of the itinerary is focused", () => {
expect(group()).toBeNull();
});

it("shows the order within the day and disables the edge buttons", async () => {
await act(async () => focusItem(1));
expect(position()).toBe("1/3");
expect(button("이전 장소").disabled).toBe(true);
expect(button("다음 장소").disabled).toBe(false);
await act(async () => focusItem(3));
expect(position()).toBe("3/3");
expect(button("다음 장소").disabled).toBe(true);
});

it("moves the focus like a card click without opening the detail and asks the list to reveal it", async () => {
await act(async () => focusItem(1));
await act(async () => button("다음 장소").click());
expect(usePlanMapFocusStore.getState().focus).toMatchObject({
roomId: "room", scheduleId: 10, itemId: 2, googlePlaceId: "gb", location: { lat: 2, lng: 2 }, source: "stepper",
});
expect(state.select).toHaveBeenCalledWith(null);
expect(position()).toBe("2/3");
expect(state.toast).not.toHaveBeenCalled();
});

it("keeps places without a location in the order and tells the user the map cannot move", async () => {
await act(async () => focusItem(2));
await act(async () => button("다음 장소").click());
expect(usePlanMapFocusStore.getState().focus).toMatchObject({ itemId: 3, location: null, source: "stepper" });
expect(state.toast).toHaveBeenCalledWith(PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE);
});
95 changes: 95 additions & 0 deletions src/components/map/PlanRouteStepper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use client";

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";

import { ChevronLeftIcon } from "@/assets/icons";
import { useSelectedPlace } from "@/contexts/SelectedPlaceContext";
import { useRoomSchedules } from "@/hooks/useRooms";
import { readSchedulePlanPlacesFromCache } from "@/lib/plan/scheduleItemPlaces";
import { scheduleItemsQueryKey } from "@/lib/query-keys";
import {
PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE,
planPlaceFocusTarget,
usePlanMapFocusStore,
} from "@/stores/plan-map-focus-store";

/** 좌우 버튼 — 누를 수 있는 영역이 숫자와 구분되도록 올리거나 누르면 둥근 배경을 깐다. 높이는 테두리 안쪽을 채운다 */
const STEP_BUTTON_CLASS =
"flex h-full w-12 items-center justify-center rounded-full text-primary transition-colors enabled:cursor-pointer enabled:hover:bg-fill enabled:active:bg-fill-strong disabled:text-icon-disabled focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary";

/**
* 웹 동선보기 장소 이동 — 일정에서 고른 장소의 일차 안에서 이전·다음 장소로 넘긴다(Figma 6035:865).
* 넘기면 카드를 누른 것처럼 카드·번호 핀 강조와 지도 중심을 바꾸고 상세는 열지 않는다. 일정 목록은 그 카드로 스크롤한다.
* 순서는 카드·번호 핀과 같은 일차 장소 순서다.
*/
export function PlanRouteStepper({ roomId }: Readonly<{ roomId: string | null }>) {
const rid = typeof roomId === "string" ? roomId.trim() : "";
const queryClient = useQueryClient();
const { isSuccess: schedulesHydrated } = useRoomSchedules(rid || null);
const { setSelectedPlace } = useSelectedPlace();
const focus = usePlanMapFocusStore((s) => (s.focus && s.focus.roomId === rid ? s.focus : null));

Check warning on line 31 in src/components/map/PlanRouteStepper.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=uttae_frontend-server&issues=AaE_2kZ7zxdh5lngwvQn&open=AaE_2kZ7zxdh5lngwvQn&pullRequest=241
const focusPlace = usePlanMapFocusStore((s) => s.focusPlace);
const scheduleId = focus?.scheduleId ?? null;

// 지도 경로(`PlanItineraryMapRoutes`)와 같은 일정 항목 캐시를 읽는다 — 따로 조회하지 않는다
const { data: places = [] } = useQuery({
queryKey: scheduleItemsQueryKey(rid || null, scheduleId),
queryFn: () => (scheduleId === null ? [] : readSchedulePlanPlacesFromCache(queryClient, rid, scheduleId)),
enabled: rid.length > 0 && scheduleId !== null && schedulesHydrated,
staleTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
});

const index = focus ? places.findIndex((place) => place.itemId === focus.itemId) : -1;
if (scheduleId === null || index < 0) return null;

const current = index + 1;
const total = places.length;
const moveTo = (nextIndex: number) => {
const place = places[nextIndex];
const target = place ? planPlaceFocusTarget(place, rid, scheduleId, "stepper") : null;
if (!target) return;
setSelectedPlace(null);
focusPlace(target);
Comment on lines +54 to +55

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 접힌 일차를 펼친 뒤 대상 카드로 이동해 주세요

장소가 두 곳 이상인 일차에서 카드를 선택하고 해당 일차를 접은 뒤 ‘다음 장소’를 누르면, 지도와 순서만 바뀌고 대상 카드는 보이지 않습니다. 일차 접기는 선택 상태를 유지하므로 스테퍼는 계속 사용할 수 있지만, moveTo는 상세를 닫고 포커스만 변경합니다. PlanPlaceCard가 scrollIntoView를 호출해도 상위 PlanDaySection에 hidden={!expanded}가 남아 있습니다.

접힌 일차의 지도 핀 선택에도 같은 한계가 기존부터 있었지만, 이번에 추가한 스테퍼 역시 이 상태에서 동작하면서 약속한 카드 표시·스크롤을 수행하지 못합니다. 스테퍼 이동 시 대상 일차가 접혀 있으면 먼저 펼치고, 카드가 표시된 뒤 스크롤되도록 처리해 주세요.

if (!target.location) toast.info(PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE);
};

return (
<div
role="group"
aria-label="장소 이동"
className="pointer-events-auto flex h-11 items-center gap-1 rounded-full border border-border bg-fill-elevate shadow-[0_2px_10px_rgba(0,0,0,0.1)]"
>

Check warning on line 64 in src/components/map/PlanRouteStepper.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <details>, <fieldset>, <optgroup>, or <address> instead of the "group" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=uttae_frontend-server&issues=AaE_2kZ7zxdh5lngwvQo&open=AaE_2kZ7zxdh5lngwvQo&pullRequest=241
<button
type="button"
aria-label="이전 장소"
disabled={index === 0}
onClick={() => moveTo(index - 1)}
className={STEP_BUTTON_CLASS}
>
<ChevronLeftIcon size={20} />
</button>
<p aria-live="polite" className="whitespace-nowrap text-text tabular-nums">
<span aria-hidden className="flex items-center gap-1">
<span className="text-label-l-emphasis">{current}</span>
<span className="text-label-m-regular">/</span>
<span className="text-label-l-emphasis">{total}</span>
</span>
<span className="sr-only">
전체 {total}곳 중 {current}번째 장소
</span>
</p>
<button
type="button"
aria-label="다음 장소"
disabled={index === total - 1}
onClick={() => moveTo(index + 1)}
className={STEP_BUTTON_CLASS}
>
<ChevronLeftIcon size={20} className="rotate-180" />
</button>
</div>
);
}
3 changes: 3 additions & 0 deletions src/lib/layout-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export const CHAT_PANEL_DOCKED_WIDTH = width.s1;
*/
export const MAIN_MAP_MIN_WIDTH_PX = 380 as const;

/** 데스크톱 장소 상세 패널이 가리는 지도 왼쪽 폭 — `MapWithDetailPanel`의 `left-2.5`(10) + `w-[360px]`와 같게 유지 */
export const MAP_DETAIL_PANEL_COVER_WIDTH_PX = 370 as const;

/** 데스크톱 준비물 상세 패널 폭 — `PackingPage.module.css`의 `.detail`과 같게 유지 */
export const PACKING_DETAIL_WIDTH_PX = 440 as const;

Expand Down
Loading
Loading