북마크에서 추가
diff --git a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
index 79717dd0..eded7da1 100644
--- a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
+++ b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
@@ -429,7 +429,8 @@ export function PlanPlaceCard({
scheduleItemId={scheduleItemId}
scopeLabel={place.title}
label="비용"
- className={cn(PLAN_PLACE_CARD_TW.actionButton, "min-w-0")}
+ // 금액이 길면 메모 버튼 앞에서 말줄임되도록 공통 버튼의 shrink-0를 푼다
+ className={cn(PLAN_PLACE_CARD_TW.actionButton, "min-w-0 shrink")}
icon={}
/>
)}
diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx
index d098b6ce..62a9f71d 100644
--- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx
+++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx
@@ -21,10 +21,6 @@ import { useMobileView } from "@/contexts/MobileViewContext";
import { PLAN_ROUTE_CARD_WIDTH_PX } from "@/lib/layout-tokens";
import { buildGoogleMapsDirectionsUrl } from "@/lib/maps";
import { cn } from "@/lib/utils";
-import {
- planMapSegmentEpochStoreKey,
- usePlanMapDirectionsEpochStore,
-} from "@/stores/plan-map-directions-epoch-store";
import { PlanTravelTimeCollapsed } from "./PlanTravelTimeCollapsed";
import { TravelDirectionsCard } from "./TravelDirectionsCard";
@@ -78,18 +74,6 @@ export function PlanTravelTime({
const fpTrim = scheduleFingerprint.trim();
const rid = roomId.trim();
- const mapRouteRenderStatus = usePlanMapDirectionsEpochStore((s) =>
- rid.length > 0
- ? s.routeRenderStatusBySegmentKey[
- planMapSegmentEpochStoreKey(
- rid,
- scheduleId,
- segmentSourceItemId,
- )
- ]
- : undefined,
- );
-
/** 서버 저장값 기준 표준 이동수단 — 이 값이 곧 방 전체 공유 상태 */
const serverMode =
canonicalScheduleTravelMode(travelMode) ?? SCHEDULE_TRAVEL_MODE_DEFAULT;
@@ -275,6 +259,7 @@ export function PlanTravelTime({
selectedMode,
serverMode,
mutateTravelMode,
+ setSelectedMode,
],
);
@@ -318,11 +303,6 @@ export function PlanTravelTime({
effectiveMode={selectedMode}
showUnknownOption={false}
modeRaw=""
- renderWarning={
- mapRouteRenderStatus === "unavailable"
- ? "해당 경로는 렌더링할 수 없어요."
- : undefined
- }
googleMapsDirectionsUrl={googleMapsDirectionsUrl ?? undefined}
subtleDirectionsLink={!isMobileDevice}
onSelectTravelMode={handleSelectTravelMode}
diff --git a/src/app/(main)/plan/_components/travel-time/TravelDirectionsCard.tsx b/src/app/(main)/plan/_components/travel-time/TravelDirectionsCard.tsx
index aed19eef..8bf43af2 100644
--- a/src/app/(main)/plan/_components/travel-time/TravelDirectionsCard.tsx
+++ b/src/app/(main)/plan/_components/travel-time/TravelDirectionsCard.tsx
@@ -40,8 +40,6 @@ export type TravelDirectionsCardProps = {
effectiveMode: ScheduleTravelModeValue;
showUnknownOption: boolean;
modeRaw: string;
- /** 지도 polyline을 만들 수 없을 때 요약 우측에 표시 */
- renderWarning?: string;
googleMapsDirectionsUrl?: string;
/** PC 일정 — 구간마다 반복되는 길찾기 링크가 튀지 않게 회색 12px로 낮춘다 */
subtleDirectionsLink?: boolean;
@@ -63,7 +61,6 @@ export function TravelDirectionsCard({
effectiveMode,
showUnknownOption,
modeRaw,
- renderWarning,
googleMapsDirectionsUrl,
subtleDirectionsLink = false,
readOnly = false,
@@ -124,15 +121,6 @@ export function TravelDirectionsCard({
) : null}
- {renderWarning ? (
-
- {renderWarning}
-
- ) : null}
{menuOpen ? (
diff --git a/src/app/(main)/plan/_components/travel-time/TravelRouteSummaryLine.tsx b/src/app/(main)/plan/_components/travel-time/TravelRouteSummaryLine.tsx
index 3d61865a..5d3d7e44 100644
--- a/src/app/(main)/plan/_components/travel-time/TravelRouteSummaryLine.tsx
+++ b/src/app/(main)/plan/_components/travel-time/TravelRouteSummaryLine.tsx
@@ -46,7 +46,7 @@ export function TravelRouteSummaryLine({
);
}
- if (isError) return "이동 시간을 불러오지 못했어요";
+ if (isError) return 경로 조회 오류;
if (routeUnavailable) return "이동 안내 없음";
return "다음 장소로 이동";
}
diff --git a/src/app/home/_components/InviteRoomModal.tsx b/src/app/home/_components/InviteRoomModal.tsx
index 2847b6d7..9f1dbd2b 100644
--- a/src/app/home/_components/InviteRoomModal.tsx
+++ b/src/app/home/_components/InviteRoomModal.tsx
@@ -1,13 +1,10 @@
"use client";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
-
-import { AddMemberPanel } from "@/app/(main)/member-settings/_components/AddMemberPanel";
-import { SettingsDialog } from "@/components/settings/SettingsDialog";
+import { InviteRoomDialog } from "@/components/rooms/InviteRoomDialog";
import { useRoomDetail } from "@/hooks/useRoomDetail";
import type { RoomListItem } from "@/lib/api/rooms";
-import { isRoomMemberRole } from "@/lib/rooms";
+/** 홈 방 카드 메뉴의 초대 — 헤더와 같은 초대하기 모달에 이 방의 정보를 넘긴다 */
export function InviteRoomModal({ room, onClose }: {
room: RoomListItem;
onClose: () => void;
@@ -15,28 +12,15 @@ export function InviteRoomModal({ room, onClose }: {
const { data, isLoading, isError, refetch } = useRoomDetail(room.id);
return (
-
- {room.title}
- {isLoading ? (
-
- ) : isError || !data ? (
-
-
방 정보를 불러오지 못했어요.
-
-
- ) : !isRoomMemberRole(data.role) ? (
- 여행 멤버만 초대 링크를 공유할 수 있어요.
- ) : (
-
- )}
-
+ { void refetch(); }}
+ onClose={onClose}
+ />
);
}
diff --git a/src/app/home/_components/RoomCardMenu.test.tsx b/src/app/home/_components/RoomCardMenu.test.tsx
index ade0e36e..4e47173e 100644
--- a/src/app/home/_components/RoomCardMenu.test.tsx
+++ b/src/app/home/_components/RoomCardMenu.test.tsx
@@ -64,7 +64,7 @@ afterEach(async () => {
it("opens the selected room's invite link without regenerating it and restores focus on Escape", async () => {
await openInvite();
- expect(document.querySelector("dialog[open]")?.textContent).toContain("제주 여행");
+ expect(document.querySelector("dialog[open] h2")?.textContent).toBe("초대하기");
expect(state.detailId).toHaveBeenCalledWith("selected-room");
expect(state.regenerate).not.toHaveBeenCalled();
await click("복사");
@@ -92,11 +92,27 @@ it.each(["loading", "error"])("does not issue links while detail is %s", async (
state.error = mode === "error";
await openInvite();
expect(state.regenerate).not.toHaveBeenCalled();
- expect(button("복사")).toBeUndefined();
+ expect(document.querySelector('input[aria-label="초대 링크"]')).toBeNull();
+ expect(button("복사").disabled).toBe(true);
+ expect(button("공유").disabled).toBe(true);
+ await click("복사");
+ await click("공유");
+ expect(state.copy).not.toHaveBeenCalled();
+ expect(state.regenerate).not.toHaveBeenCalled();
if (mode === "error") {
+ expect(document.querySelector('[role="alert"]')?.textContent).toContain("방 정보를 불러오지 못했어요");
await click("다시 시도");
expect(state.refetch).toHaveBeenCalledOnce();
+ expect(state.regenerate).not.toHaveBeenCalled();
}
+ state.loading = false;
+ state.error = false;
+ state.detail!.inviteCode = "recovered-code";
+ await render();
+ expect(button("다시 시도")).toBeUndefined();
+ await click("복사");
+ expect(state.copy).toHaveBeenCalledExactlyOnceWith(`${window.location.origin}/join/recovered-code`);
+ expect(state.regenerate).not.toHaveBeenCalled();
});
it("reports clipboard failure without claiming the link was copied", async () => {
diff --git a/src/app/home/_components/RoomGrid.tsx b/src/app/home/_components/RoomGrid.tsx
index 30ecdbe8..b6a65de2 100644
--- a/src/app/home/_components/RoomGrid.tsx
+++ b/src/app/home/_components/RoomGrid.tsx
@@ -66,7 +66,7 @@ export function RoomGrid({
}
return (
-
+
{rooms.map((room, index) => (
-
({ mobile: true, busy: false }));
+vi.mock("@/contexts/MobileViewContext", () => ({
+ useMobileView: () => ({ isMobileDevice: state.mobile }),
+}));
+vi.mock("./ExpenseProvider", () => ({
+ useExpenseContext: () => ({
+ roomId: "r", currentUserId: 1, members: [], memberStatus: "success",
+ canManage: true, schedules: [], schedulesReady: true, busy: state.busy,
+ currencies: { data: [{ currency: "KRW", fractionDigits: 0, maximumAmount: "999999999999999" }], isSuccess: true },
+ list: { data: [], isSuccess: true }, save: vi.fn(), readLatest: vi.fn(),
+ }),
+}));
+vi.mock("@/hooks/useRooms", () => ({
+ useSchedulePlanPlaces: () => ({ data: [], isSuccess: true, isPending: false, isError: false }),
+}));
+
+let root: Root;
+let host: HTMLDivElement;
+const close = vi.fn();
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ HTMLDialogElement.prototype.showModal = function () { this.open = true; };
+ HTMLDialogElement.prototype.close = function () { this.open = false; };
+ state.mobile = true; state.busy = false; close.mockReset();
+ host = document.createElement("div"); document.body.append(host); root = createRoot(host);
+});
+afterEach(async () => {
+ await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals();
+});
+async function pointer(target: Element, type: string, button = 0) {
+ await act(async () => target.dispatchEvent(new MouseEvent(type, { bubbles: true, button })));
+}
+
+for (const kind of ["editor", "list"] as const) {
+ describe(`${kind} backdrop dismissal`, () => {
+ async function mount() {
+ await act(async () => root.render(kind === "editor"
+ ?
+ : {}}
+ onEdit={() => {}} onDelete={async () => {}} onRetry={() => {}} onClose={close} />));
+ return host.querySelector("dialog")!;
+ }
+ it("dismisses once when a mobile backdrop press is released", async () => {
+ const dialog = await mount();
+ await pointer(dialog, "pointerdown");
+ expect(close).not.toHaveBeenCalled();
+ await pointer(dialog, "pointerup");
+ expect(close).toHaveBeenCalledOnce();
+ await pointer(dialog, "pointerup");
+ expect(close).toHaveBeenCalledOnce();
+ });
+ it("keeps content interactions and gestures ending outside open", async () => {
+ const dialog = await mount();
+ const content = host.querySelector("h2")!;
+ await pointer(content, "pointerdown");
+ await pointer(dialog, "pointerup");
+ await pointer(dialog, "pointerdown");
+ await pointer(content, "pointerup");
+ await pointer(content, "pointerdown");
+ await pointer(content, "pointerup");
+ expect(close).not.toHaveBeenCalled();
+ });
+ it("discards a cancelled backdrop gesture", async () => {
+ const dialog = await mount();
+ await pointer(dialog, "pointerdown");
+ await pointer(dialog, "pointercancel");
+ await pointer(dialog, "pointerup");
+ expect(close).not.toHaveBeenCalled();
+ });
+ it("ignores secondary-button backdrop presses", async () => {
+ const dialog = await mount();
+ await pointer(dialog, "pointerdown", 2);
+ await pointer(dialog, "pointerup", 2);
+ expect(close).not.toHaveBeenCalled();
+ });
+ it("keeps desktop backdrop presses open", async () => {
+ state.mobile = false;
+ const dialog = await mount();
+ await pointer(dialog, "pointerdown"); await pointer(dialog, "pointerup");
+ expect(close).not.toHaveBeenCalled();
+ });
+ it("blocks backdrop and Escape dismissal while busy", async () => {
+ state.busy = true;
+ const dialog = await mount();
+ await pointer(dialog, "pointerdown"); await pointer(dialog, "pointerup");
+ const cancel = new Event("cancel", { cancelable: true });
+ await act(async () => dialog.dispatchEvent(cancel));
+ expect(cancel.defaultPrevented).toBe(true);
+ expect(close).not.toHaveBeenCalled();
+ });
+ it("preserves Escape dismissal without a pointer gesture", async () => {
+ const dialog = await mount();
+ const cancel = new Event("cancel", { cancelable: true });
+ await act(async () => dialog.dispatchEvent(cancel));
+ expect(cancel.defaultPrevented).toBe(true);
+ expect(close).toHaveBeenCalledOnce();
+ });
+ });
+}
diff --git a/src/components/expenses/ExpenseCurrencyPicker.tsx b/src/components/expenses/ExpenseCurrencyPicker.tsx
index e67eef01..2697c257 100644
--- a/src/components/expenses/ExpenseCurrencyPicker.tsx
+++ b/src/components/expenses/ExpenseCurrencyPicker.tsx
@@ -97,7 +97,7 @@ export function ExpenseCurrencyPicker({
className="mt-1 flex h-12 w-full min-w-0 items-center gap-2 rounded-lg border border-border bg-fill-subtle px-3 py-2 text-left text-[14px] leading-5 font-medium cursor-pointer transition-colors enabled:hover:border-primary/40 enabled:hover:bg-gray-50 aria-expanded:border-primary/50 aria-expanded:bg-primary/5 focus-visible:outline-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50"
>
- {value || "통화 선택"}
+ {value || "통화 선택"}
{value && (
{currencyName(value)}
diff --git a/src/components/expenses/ExpenseDialog.drag.test.tsx b/src/components/expenses/ExpenseDialog.drag.test.tsx
index 531b36bd..96b91443 100644
--- a/src/components/expenses/ExpenseDialog.drag.test.tsx
+++ b/src/components/expenses/ExpenseDialog.drag.test.tsx
@@ -11,6 +11,7 @@ const capture = vi.fn();
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", () => ({ matches: true }));
+ document.documentElement.classList.add("is-mobile-device");
HTMLDialogElement.prototype.showModal = function () { this.open = true; };
HTMLDialogElement.prototype.close = function () { this.open = false; };
HTMLElement.prototype.setPointerCapture = capture;
@@ -46,8 +47,8 @@ it.each([[30, "pointerup"], [130, "pointercancel"], [-100, "pointerup"]])("snaps
expect(close).not.toHaveBeenCalled();
expect(container.querySelector("dialog")!.style.transform).not.toContain("translateY(");
});
-it("ignores desktop drags but supports wide mobile devices", () => {
- vi.stubGlobal("matchMedia", () => ({ matches: false }));
+it("ignores desktop drags even on narrow screens and supports mobile devices", () => {
+ document.documentElement.classList.remove("is-mobile-device");
drag(130);
expect(close).not.toHaveBeenCalled();
document.documentElement.classList.add("is-mobile-device");
@@ -125,7 +126,7 @@ it("dismisses the day/place list sheet by dragging its header", () => {
expect(close).toHaveBeenCalledOnce();
});
it("leaves the desktop scope panel transform untouched", () => {
- vi.stubGlobal("matchMedia", () => ({ matches: false }));
+ document.documentElement.classList.remove("is-mobile-device");
mountScope();
drag(130);
expect(close).not.toHaveBeenCalled();
diff --git a/src/components/expenses/ExpenseDialog.tsx b/src/components/expenses/ExpenseDialog.tsx
index ac126bd8..58c04c1d 100644
--- a/src/components/expenses/ExpenseDialog.tsx
+++ b/src/components/expenses/ExpenseDialog.tsx
@@ -53,36 +53,36 @@ export function ExpenseDialog({
event.preventDefault();
onClose();
}}
- className={`fixed inset-0 m-auto ${settlement ? "mobile:h-[calc(100dvh-92px)] mobile:max-h-[calc(100dvh-92px)] max-sm:h-[calc(100dvh-92px)] max-sm:max-h-[calc(100dvh-92px)]" : "mobile:h-[70dvh] mobile:max-h-[70dvh] max-sm:h-[70dvh] max-sm:max-h-[70dvh]"} max-h-[calc(100dvh-3rem)] w-[calc(100%-2rem)] max-w-[720px] overflow-hidden rounded-xl border-0 bg-white p-0 text-text shadow-xl backdrop:bg-black/40 max-sm:mb-0 max-sm:w-full max-sm:rounded-b-none mobile:mb-0 mobile:w-full mobile:rounded-b-none mobile:rounded-t-[20px] max-sm:rounded-t-[20px]`}
+ className={`fixed inset-0 m-auto ${settlement ? "mobile:h-[calc(100dvh-92px)] mobile:max-h-[calc(100dvh-92px)]" : "mobile:h-[70dvh] mobile:max-h-[70dvh]"} max-h-[calc(100dvh-3rem)] w-[calc(100%-2rem)] max-w-[720px] overflow-hidden rounded-xl border-0 bg-white p-0 text-text shadow-xl backdrop:bg-black/40 mobile:backdrop:bg-[#0f1724]/35 mobile:backdrop:animate-in mobile:backdrop:fade-in-0 mobile:backdrop:duration-200 mobile:animate-in mobile:slide-in-from-bottom mobile:duration-200 mobile:mb-0 mobile:w-full mobile:max-w-none mobile:rounded-b-none mobile:rounded-t-[20px]`}
>
-
-
+
+
-
+
-
+
{title}
- {description &&
{description}
}
+ {description &&
{description}
}
-
+
{children}
{footer && (
-