+ {/* 에러 문구는 내부 메시지(요청 id·API 오류 코드)라 화면에 그대로 보여 주지 않는다 */}
- {firstPlaceError instanceof Error
- ? firstPlaceError.message
- : "장소 정보를 불러오지 못했습니다."}
+ 장소 정보를 불러오지 못했습니다.
diff --git a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
index e8a1e9b6..0619a9db 100644
--- a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
+++ b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
@@ -17,6 +17,7 @@ import {
useUpdateScheduleItem,
} from "@/hooks/useRooms";
import { usePlanPlaceCardPhoto } from "@/hooks/usePlanPlaceCardPhoto";
+import { useVisiblePlacePhotoUrl } from "@/hooks/useVisiblePlacePhotoUrl";
import { bucketItemCount } from "@/lib/analytics/context";
import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track";
import { PLAN_PLACE_CARD_TW } from "@/lib/layout-tokens";
@@ -106,6 +107,8 @@ export function PlanPlaceCard({
const { resolvedPhotoUrl, photoLoading } = usePlanPlaceCardPhoto(place, {
enabled: thumbnailInViewport,
});
+ const { visibleUrl: visiblePhotoUrl, markUnavailable: markPhotoUnavailable } =
+ useVisiblePlacePhotoUrl(resolvedPhotoUrl);
const [memoOpen, setMemoOpen] = useState(false);
const [timeOpen, setTimeOpen] = useState(false);
@@ -320,9 +323,9 @@ export function PlanPlaceCard({
className="absolute inset-0 m-auto h-5 w-5 animate-spin text-primary-strong"
aria-hidden
/>
- ) : resolvedPhotoUrl ? (
+ ) : visiblePhotoUrl ? (
diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx
new file mode 100644
index 00000000..90bd5f8d
--- /dev/null
+++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx
@@ -0,0 +1,52 @@
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { PlanTravelTime } from "./PlanTravelTime";
+
+const selectedRoute = vi.hoisted(() => ({
+ current: { data: undefined, isPending: false, isError: false, isFetching: false } as Record
,
+}));
+
+vi.mock("@/hooks/useRooms", () => ({
+ useScheduleItemRoute: () => selectedRoute.current,
+ useUpdateScheduleItemTravelMode: () => ({ mutate: vi.fn() }),
+}));
+vi.mock("@tanstack/react-query", () => ({
+ useQueries: ({ queries }: { queries: unknown[] }) => queries.map(() => ({ data: undefined })),
+}));
+vi.mock("@/contexts/MobileViewContext", () => ({ useMobileView: () => ({ isMobileDevice: false }) }));
+
+function renderSummary() {
+ return renderToStaticMarkup(
+ createElement(PlanTravelTime, {
+ roomId: "room-1",
+ scheduleId: 1,
+ segmentSourceItemId: 100,
+ scheduleFingerprint: "100:a:|101:b:",
+ travelMode: "DRIVING",
+ contentOnly: true,
+ }),
+ );
+}
+
+describe("PlanTravelTime 요약 줄", () => {
+ beforeEach(() => {
+ selectedRoute.current = { data: undefined, isPending: false, isError: false, isFetching: false };
+ });
+
+ it("다시 받다가 실패해도 마지막 답이 '경로 없음'이면 오류 대신 그 답을 보여 준다", () => {
+ selectedRoute.current = { data: null, isPending: false, isError: true, isFetching: false };
+
+ const html = renderSummary();
+
+ expect(html).toContain("이동 안내 없음");
+ expect(html).not.toContain("경로 조회 오류");
+ });
+
+ it("받은 답 없이 실패하면 오류를 보여 준다", () => {
+ selectedRoute.current = { data: undefined, isPending: false, isError: true, isFetching: false };
+
+ expect(renderSummary()).toContain("경로 조회 오류");
+ });
+});
diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx
index 62a9f71d..02e11b7c 100644
--- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx
+++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx
@@ -222,8 +222,9 @@ export function PlanTravelTime({
const summaryPending =
!displayRoute && !selectedFetching && selectedPending;
+ // 마지막 답이 "경로 없음"(null)이면 다시 받다가 실패해도 그 답을 보여 준다 — 재조회 실패로 이미 받은 답을 오류로 바꾸지 않는다
const summaryIsError =
- !displayRoute && primarySettled && selectedError;
+ !displayRoute && primarySettled && selectedError && selectedRoute !== null;
const summaryUnavailable =
!displayRoute &&
diff --git a/src/components/chat/messages/OgPlacePreviewCard.tsx b/src/components/chat/messages/OgPlacePreviewCard.tsx
index 660f33cf..b578f62a 100644
--- a/src/components/chat/messages/OgPlacePreviewCard.tsx
+++ b/src/components/chat/messages/OgPlacePreviewCard.tsx
@@ -4,6 +4,7 @@ import { MapPin, Star } from "lucide-react";
import { useInViewport } from "@/hooks/useInViewport";
import { usePlacePhotoUrlQuery } from "@/hooks/usePlacePhotoUrl";
+import { useVisiblePlacePhotoUrl } from "@/hooks/useVisiblePlacePhotoUrl";
import { resolveChatMessageTypography } from "@/components/chat/chat-typography";
import { handlePlacePhotoImageError } from "@/lib/places/place-photo-refresh";
import { cn } from "@/lib/utils";
@@ -36,6 +37,7 @@ export function OgPlacePreviewCard({
const { data: imageUrl } = usePlacePhotoUrlQuery(googlePlaceId, {
enabled: thumbnailInViewport,
});
+ const { visibleUrl, markUnavailable } = useVisiblePlacePhotoUrl(imageUrl);
return (