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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/app/(main)/map/_components/useMobileRouteView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ import { scheduleItemsQueryKey } from "@/lib/query-keys";
import { useMobileRouteView } from "./useMobileRouteView";

it.each([
{ url: "/map?view=route&day=1", camera: "fit", item: "101" },
{ url: "/map?view=route&day=1&item=102", camera: "place", item: "102" },
])("$url 진입 후 URL에 선택 장소가 반영되면 해당 장소를 확대한다", async ({ url, camera, item }) => {
// 경로 보기 버튼 — 그 일차 첫 장소를 바로 확대한다(일차 전체를 먼저 맞췄다가 확대하지 않는다)
{ url: "/map?view=route&day=1", item: "101" },
// 일정 카드 — 누른 장소를 확대한다
{ url: "/map?view=route&day=1&item=102", item: "102" },
])("$url 진입 시 처음부터 고른 장소를 확대하고, URL에 선택 장소가 반영돼도 그대로다", async ({ url, item }) => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.history.replaceState(null, "", url);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
Expand All @@ -72,7 +74,7 @@ it.each([
const render = () => root.render(<QueryClientProvider client={queryClient}><Harness /></QueryClientProvider>);
try {
await act(async () => render());
expect(current?.mapRouteView?.camera.kind).toBe(camera);
expect(current?.mapRouteView?.camera.kind).toBe("place");
expect(new URLSearchParams(window.location.search).get("item")).toBe(item);
// Next.js reflects native replaceState into useSearchParams; emulate that rerender.
await act(async () => render());
Expand Down
6 changes: 4 additions & 2 deletions src/app/(main)/map/_components/useMobileRouteView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,9 @@ export function useMobileRouteView({
if (index >= 0) focusEntry(index, "pan");
},
// 처음 들어올 때 — 일정 카드로 들어왔으면 그 장소를 가까이, 경로 보기 버튼이면 일차 전체를 보여 준다
camera: focus.key === null ? { kind: initialItemId !== null ? "place" : "fit", seq: 0 } : focus.camera,
// 처음 들어올 때는 일정 카드로 왔든 경로 보기 버튼으로 왔든 고른 장소(버튼은 그 일차 첫 장소)를 바로 확대한다 —
// 일차 전체를 먼저 맞췄다가 확대하면 지도가 두 번 움직인다. 일차 전체(fit)는 일차 탭·다른 일차로 넘길 때만 쓴다
camera: focus.key === null ? { kind: "place", seq: 0 } : focus.camera,
locations,
expanded: isExpanded,
candidate:
Expand Down Expand Up @@ -349,7 +351,7 @@ export function useMobileRouteView({
setExpanded(false);
setCandidate(null);
setPreviewIndex(null);
setFocus((prev) => ({ key: null, index: 0, camera: { kind: "fit", seq: prev.camera.seq + 1 } }));
setFocus((prev) => ({ key: null, index: 0, camera: { kind: "place", seq: prev.camera.seq + 1 } }));
router.replace(buildMapRouteHref(1));
};

Expand Down
4 changes: 4 additions & 0 deletions src/app/(main)/plan/_components/itinerary/PlanPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useDeleteRoomSchedule,
useRoomSchedules,
} from "@/hooks/useRooms";
import { usePlanMapFocusStore } from "@/stores/plan-map-focus-store";
import { useSessionStore } from "@/stores/session-store";
import { usePlanItineraryExpandedStore } from "@/stores/plan-itinerary-expanded-store";
import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store";
Expand Down Expand Up @@ -39,6 +40,9 @@ import { PlanScheduleDayBlock } from "./PlanScheduleDayBlock";
export function PlanPageView() {
const planContainerRef = useRef<HTMLDivElement>(null);
const { isMobileDevice } = useMobileView();
// 일정 화면을 떠나면 고른 장소(카드 강조·지도의 "이 일차만 보기")를 푼다 — 검색·북마크 탭에서도 지도는 그대로라서
const clearPlanFocus = usePlanMapFocusStore((s) => s.clearFocus);
useEffect(() => () => clearPlanFocus(), [clearPlanFocus]);

//room id 가져와서 room detail, schedules 가져오기
const storedId = useSessionStore((s) => s.currentRoomId);
Expand Down
102 changes: 67 additions & 35 deletions src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ExpenseEntryButton } from "@/components/expenses/ExpenseProvider";
import { ExpenseIcon } from "@/components/icons/ExpenseIcon";

import type { DragEvent } from "react";
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import { Clock, Loader2, MapPin } from "lucide-react";

Expand All @@ -13,22 +13,22 @@ import { ConfirmDialog } from "@/components/settings/ConfirmDialog";
import { toast } from "sonner";

import { useInViewport } from "@/hooks/useInViewport";
import { useOpenPlaceOnMap } from "@/hooks/useOpenPlaceOnMap";
import {
useDeleteScheduleItem,
useUpdateScheduleItem,
} from "@/hooks/useRooms";
import { usePlanPlaceCardPhoto } from "@/hooks/usePlanPlaceCardPhoto";
import { bucketItemCount } from "@/lib/analytics/context";
import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track";
import { normalizeGooglePlaceResourceId } from "@/lib/maps";
import { PLAN_PLACE_CARD_TW } from "@/lib/layout-tokens";
import { handlePlacePhotoImageError } from "@/lib/places/place-photo-refresh";
import {
formatScheduleTimeRange,
} from "@/lib/plan/scheduleTime";
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 { PlanItemMemoEditor, PlanItemMemoReadOnly } from "./PlanItemMemoForm";
import { PlanItemTimeEditor } from "./PlanItemTimeForm";
Expand Down Expand Up @@ -68,7 +68,34 @@ export function PlanPlaceCard({
onDragStart,
onDragEnd,
}: PlanPlaceCardProps) {
const openPlaceOnMap = useOpenPlaceOnMap();
const { setSelectedPlace } = useSelectedPlace();
const focusPlace = usePlanMapFocusStore((s) => s.focusPlace);
const clearFocus = usePlanMapFocusStore((s) => s.clearFocus);
const focusRoomId = scheduleTimeEdit?.roomId.trim() ?? "";
const focusScheduleId = scheduleTimeEdit?.scheduleId ?? null;
/** 고른 장소 — 다른 카드를 고르거나, 같은 카드를 다시 누르거나, 지도 빈 곳을 누를 때까지 강조해 둔다 */
const focused = usePlanMapFocusStore(
(s) =>
s.focus !== null &&
s.focus.roomId === focusRoomId &&
s.focus.scheduleId === focusScheduleId &&
s.focus.itemId === place.itemId,
);
/** 지도 핀으로 고른 경우에만 값이 있다 — 그때 일정 목록을 이 카드로 스크롤한다 */
const focusSeqFromMap = usePlanMapFocusStore((s) =>
s.focus?.source === "map" &&
s.focus.roomId === focusRoomId &&
s.focus.scheduleId === focusScheduleId &&
s.focus.itemId === place.itemId
? s.focus.seq
: null,
);
const articleRef = useRef<HTMLElement>(null);
useEffect(() => {
if (focusSeqFromMap === null) return;
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
articleRef.current?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" });
}, [focusSeqFromMap]);
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const blockCardDragRef = useRef(false);
const [thumbnailRef, thumbnailInViewport] = useInViewport<HTMLElement>();
Expand Down Expand Up @@ -169,42 +196,42 @@ export function PlanPlaceCard({
Math.abs(e.clientX - start.x) + Math.abs(e.clientY - start.y);
if (moved > 12) return;
}
const loc = place.location;
if (
!loc ||
typeof loc.lat !== "number" ||
typeof loc.lng !== "number" ||
!Number.isFinite(loc.lat) ||
!Number.isFinite(loc.lng)
) {
toast.info("지도에 표시할 위치 정보가 없어요.");
if (!focusRoomId || focusScheduleId === null || typeof place.itemId !== "number") return;
// 고른 카드를 다시 누르면 강조와 "이 일차 경로만 보기"를 푼다
if (focused) {
clearFocus();
setSelectedPlace(null);
return;
}
const rawId =
typeof place.googlePlaceId === "string"
? place.googlePlaceId.trim()
: "";
const gid =
rawId.length > 0 ? normalizeGooglePlaceResourceId(rawId) : undefined;

openPlaceOnMap(
{
name: place.title,
category: "",
rating: null,
...(gid ? { googlePlaceId: gid } : {}),
location: { lat: loc.lat, lng: loc.lng },
address: place.subtitle,
},
{ analyticsSource: "plan" },
);
const loc = place.location;
const hasLocation =
loc != null &&
typeof loc.lat === "number" &&
typeof loc.lng === "number" &&
Number.isFinite(loc.lat) &&
Number.isFinite(loc.lng);
// 일정에 넣은 장소는 상세를 보려는 게 아니라 위치를 보려는 것 — 상세(조회 포함) 대신 카드·지도 핀만 강조한다
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("지도에 표시할 위치 정보가 없어요.");
},
[
openPlaceOnMap,
clearFocus,
focusPlace,
focusRoomId,
focusScheduleId,
focused,
place.googlePlaceId,
place.itemId,
place.location,
place.subtitle,
place.title,
setSelectedPlace,
],
);

Expand Down Expand Up @@ -283,14 +310,19 @@ export function PlanPlaceCard({
return (
<div className="flex w-full flex-col gap-2">
<article
ref={articleRef}
draggable={!dragDisabled}
onPointerDownCapture={handlePointerDownCapture}
onDragStart={dragDisabled ? undefined : handleDragStart}
onDragEnd={dragDisabled ? undefined : handleDragEnd}
onClick={handleCardClick}
aria-current={focused || undefined}
className={cn(
"w-full select-none",
"w-full select-none transition-colors",
PLAN_PLACE_CARD_TW.article,
// 고른 카드는 계속 켜져 있으므로 배경은 그대로 두고 테두리와 옅게 번지는 파란 그림자로만 표시한다
// (그림자 토큰이 없어 파랑 원시 토큰으로 지정)
focused && "border-primary shadow-[0_0_12px_var(--blue-200)]",
dragDisabled
? "cursor-default"
: "cursor-grab active:cursor-grabbing",
Expand Down
22 changes: 4 additions & 18 deletions src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,6 @@ export function PlanTravelTime({
fpTrim,
);

const deferredLazyEnabled =
routeQueryEnabled &&
menuOpen &&
rid.length > 0 &&
Number.isFinite(scheduleId) &&
Number.isFinite(segmentSourceItemId);

const menuDeferredModes = useMemo(
() =>
SCHEDULE_TRAVEL_MODES.map((m) => m.value).filter(
Expand All @@ -144,6 +137,8 @@ export function PlanTravelTime({
[selectedMode],
);

// 고르지 않은 수단은 조회하지 않는다(경로 조회 비용) — 예전에 고른 적이 있어 캐시에 남은 값만 보여 주고,
// 나머지는 그 수단을 골라야 계산한다
const deferredRouteQueries = useQueries({
queries: menuDeferredModes.map((mode) =>
persistedScheduleItemRouteQueryOptions(
Expand All @@ -154,7 +149,7 @@ export function PlanTravelTime({
fpTrim,
{
segmentReady: routeQueryEnabled,
networkEnabled: deferredLazyEnabled,
networkEnabled: false,
},
),
),
Expand Down Expand Up @@ -222,27 +217,18 @@ export function PlanTravelTime({
s.distanceMeters >= 0
);
};
// 고르지 않은 수단은 조회하지 않으니 고른 수단만 불러오는 중을 표시한다
if (routeQueryEnabled) {
out[selectedMode] =
!hasSummary(selectedMode) && (selectedPending || selectedFetching);
menuDeferredModes.forEach((mode, i) => {
const q = deferredRouteQueries[i];
out[mode] =
deferredLazyEnabled &&
!hasSummary(mode) &&
Boolean(q?.isPending || q?.isFetching);
});
}
return out;
}, [
modeRouteSummaries,
routeQueryEnabled,
selectedMode,
deferredLazyEnabled,
selectedPending,
selectedFetching,
menuDeferredModes,
deferredRouteQueries,
]);

const primarySettled = !selectedPending && !selectedFetching;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,14 @@ export function TravelDirectionsCard({
<p className="mb-1.5 text-body-xs-emphasis font-semibold uppercase tracking-wide text-dark-gray">
이동 수단
</p>
{/* 아이콘 | 수단 | 시간·거리 세 칸을 모든 줄이 함께 써서(subgrid), 시간·거리가 가장 긴 수단 이름 뒤 오른쪽 끝에 맞춰진다 */}
<ul
className="flex flex-col gap-1"
className="grid grid-cols-[auto_auto_auto] gap-x-2 gap-y-1"
role={readOnly ? undefined : "listbox"}
>
{SCHEDULE_TRAVEL_MODES.map(({ value }) => {
const rowSummary = modeRouteSummaries[value];
const selected = value === effectiveMode;
const row =
rowSummary &&
rowSummary.durationSeconds >= 0 &&
Expand All @@ -151,39 +153,35 @@ export function TravelDirectionsCard({
<span className="inline-flex items-center gap-1 text-dark-gray">
<Loader2 className="h-3 w-3 animate-spin" />
</span>
) : (
<span className="text-dark-gray">—</span>
);
const selected = value === effectiveMode;
) : null; // 고르지 않은 수단은 골라야 계산한다(미리 조회하지 않음)
const rowClass = cn(
"flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-body-s-regular mobile:text-body-xs-regular",
"col-span-3 grid grid-cols-subgrid items-center rounded-md px-1.5 py-1.5 text-left text-body-s-regular mobile:text-body-xs-regular",
!readOnly && "hover:bg-gray-border/30",
selected &&
(readOnly ?
"bg-gray-50 ring-1 ring-gray-border/40"
: "bg-primary/10 ring-1 ring-primary/25"),
);

// 고른 수단도 다른 줄처럼 시간·거리를 보이고, 배경 강조로만 구분한다
const rowValue = <span className="justify-self-end whitespace-nowrap pl-3 text-dark-gray">{row}</span>;

if (readOnly) {
return (
<li key={value}>
<li key={value} className="col-span-3 grid grid-cols-subgrid">
<div className={rowClass}>
<TravelModeGlyph mode={value} />
<span className="font-medium text-gray-900">
{scheduleTravelModeLabel(value)}
</span>
{selected ?
<span className="ml-auto shrink-0 text-body-xs-emphasis font-semibold text-dark-gray">
적용 중
</span>
: <span className="ml-auto text-dark-gray">{row}</span>}
{rowValue}
</div>
</li>
);
}

return (
<li key={value}>
<li key={value} className="col-span-3 grid grid-cols-subgrid">
<button
type="button"
role="option"
Expand All @@ -200,19 +198,13 @@ export function TravelDirectionsCard({
<span className="font-medium text-gray-900">
{scheduleTravelModeLabel(value)}
</span>
{selected ? (
<span className="ml-auto shrink-0 text-body-xs-emphasis font-semibold uppercase text-primary">
선택됨
</span>
) : (
<span className="ml-auto text-dark-gray">{row}</span>
)}
{rowValue}
</button>
</li>
);
})}
{showUnknownOption && !readOnly ? (
<li>
<li className="col-span-3">
<button
type="button"
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-label-m-regular mobile:text-label-s-regular hover:bg-gray-border/30 disabled:cursor-not-allowed disabled:opacity-50"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Bike, Bus, Car, Footprints } from "lucide-react";
import { Bus, Car, Footprints } from "lucide-react";

export function TravelModeGlyph({ mode }: { mode: string }) {
const key = mode.trim().toUpperCase();
Expand All @@ -12,7 +12,5 @@ export function TravelModeGlyph({ mode }: { mode: string }) {
return <Car className="h-4 w-4 shrink-0 text-primary" />;
if (key.includes("TRANSIT") || key.includes("BUS") || key.includes("SUBWAY"))
return <Bus className="h-4 w-4 shrink-0 text-primary" />;
if (key.includes("CYCL") || key === "CYCLING" || key.includes("BICY"))
return <Bike className="h-4 w-4 shrink-0 text-primary" />;
return <Footprints className="h-4 w-4 shrink-0 text-primary" />;
}
Loading
Loading