diff --git a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx index 98122c1d..7f285671 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx @@ -146,9 +146,10 @@ export function MobileRouteExpandedShell({ resetDrag(); }} onPointerCancel={resetDrag} - className="flex h-8 w-full shrink-0 cursor-pointer touch-pan-x items-end justify-center pb-2" + // 바텀시트 핸들과 같이 위·아래 12px — 아래 12px는 카드 위 여백과 겹쳐 누르는 영역만 넓힌다(테두리 1px 포함 바깥에서 12px) + className="relative z-10 -mb-3 flex w-full shrink-0 cursor-pointer touch-pan-x justify-center pb-3 pt-[11px]" > - +
{children}
diff --git a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx index 04993a86..3ae9471a 100644 --- a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx +++ b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx @@ -40,9 +40,10 @@ function InviteFallback({ canIssue, inviteCode, isRoomDetailLoading, isRoomDetai return ; } let message = "발급에 실패했어요. 아래에서 재발급을 눌러 주세요."; - if (!canIssue) message = "초대 링크를 사용할 수 없어요. 방 정보를 다시 확인해 주세요."; - else if (isRoomDetailError || inviteCode === undefined) message = "방 정보를 불러오지 못했어요. 아래에서 재발급을 눌러 주세요."; - return {message}; + if (isRoomDetailError) message = "방 정보를 불러오지 못했어요."; + else if (!canIssue) message = "초대 링크를 사용할 수 없어요. 방 정보를 다시 확인해 주세요."; + else if (inviteCode === undefined) message = "방 정보를 불러오지 못했어요. 아래에서 재발급을 눌러 주세요."; + return {message}; } export function AddMemberPanel({ diff --git a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx index 5d5d4cce..74a0da5e 100644 --- a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx +++ b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx @@ -148,7 +148,7 @@ export function AddFromBookmarkModal({ useCreateScheduleItem(); const busy = isCreatingItem || addingGooglePlaceId != null; - const sheetDrag = useSheetDrag(onClose, busy, "(max-width: 767px)"); + const sheetDrag = useSheetDrag(onClose, busy); const handleAddPlace = useCallback( async (googlePlaceId: string) => { @@ -226,11 +226,14 @@ export function AddFromBookmarkModal({ return (
{ - if (ev.target === ev.currentTarget) onClose(); - }} + className="fixed inset-0 z-[60] flex flex-col justify-center px-4 mobile:justify-end mobile:px-0" > + {/* 딤은 시트와 따로 둬서 기준 시트처럼 딤만 서서히 나타나고 시트는 아래에서 올라온다 */} +
e.stopPropagation()} className={cn( - "flex max-h-[min(85vh,560px)] w-full flex-col overflow-hidden", - "border-t border-gray-border bg-white shadow-[0_-8px_32px_-8px_rgba(0,0,0,0.12)] md:rounded-2xl md:border md:shadow-lg", - "rounded-t-[1.35rem]", - "mx-auto md:max-w-md", + "relative flex max-h-[min(85vh,560px)] w-full flex-col overflow-hidden", + // PC는 가운데 모달, 모바일 기기는 화면 폭과 관계없이 바텀시트 + "mx-auto max-w-md rounded-2xl border border-gray-border bg-white shadow-lg", + "mobile:max-w-none mobile:rounded-b-none mobile:rounded-t-[20px] mobile:border-x-0 mobile:border-b-0 mobile:shadow-[0_-8px_32px_-8px_rgba(0,0,0,0.12)]", + "mobile:animate-in mobile:slide-in-from-bottom mobile:duration-200", "pb-[max(env(safe-area-inset-bottom,0px),12px)]", )} > - +

북마크에서 추가

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 && ( -
      +
      {footer}
      )} diff --git a/src/components/expenses/ExpenseEditor.tsx b/src/components/expenses/ExpenseEditor.tsx index 014df4be..4fc3a7e5 100644 --- a/src/components/expenses/ExpenseEditor.tsx +++ b/src/components/expenses/ExpenseEditor.tsx @@ -1,5 +1,6 @@ "use client"; import { LoadingIndicator } from "@/components/loading/LoadingIndicator"; +import { useMobileView } from "@/contexts/MobileViewContext"; import { useSheetDrag } from "@/components/mobile/useSheetDrag"; import { BottomSheetDragHandle } from "@/components/mobile/BottomSheetDragHandle"; @@ -206,6 +207,9 @@ export function ExpenseEditor({ }, []); const pending = saving || context.busy; const sheetDrag = useSheetDrag(onClose, pending); + const { isMobileDevice } = useMobileView(); + // 바텀시트는 기준 시트처럼 딤을 눌러 닫는다. 시트 안에서 시작한 드래그가 딤에서 끝나면 닫지 않는다. + const backdropPress = useRef(false); const currentTarget = context.list?.data?.find( (e) => e.id === original?.id, ); @@ -332,43 +336,55 @@ export function ExpenseEditor({ event.preventDefault(); if (!pending) onClose(); }} - className="fixed inset-0 m-auto max-h-[calc(100dvh-3rem)] w-[calc(100%-2rem)] max-w-[640px] overflow-hidden rounded-xl border-0 bg-white p-0 text-black shadow-xl backdrop:bg-black/40 max-sm:mb-0 max-sm:w-full max-sm:rounded-b-none mobile:mb-0 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:w-full mobile:max-w-none mobile:rounded-b-none mobile:rounded-t-[20px] mobile:animate-in mobile:slide-in-from-bottom mobile:duration-200" + onPointerDown={(event) => { + backdropPress.current = event.button === 0 && event.target === event.currentTarget; + }} + onPointerUp={(event) => { + const startedOnBackdrop = backdropPress.current; + backdropPress.current = false; + if (isMobileDevice && !pending && startedOnBackdrop && event.target === event.currentTarget) onClose(); + }} + onPointerCancel={() => { + backdropPress.current = false; + }} + className="fixed inset-0 m-auto max-h-[calc(100dvh-3rem)] w-[calc(100%-2rem)] max-w-[640px] overflow-hidden rounded-xl border-0 bg-white p-0 text-black 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:mb-0 mobile:h-[calc(100dvh-92px)] mobile:max-h-[calc(100dvh-92px)] mobile:w-full mobile:max-w-none mobile:rounded-b-none mobile:rounded-t-[20px] mobile:animate-in mobile:slide-in-from-bottom mobile:duration-200" >
      - -
      -
      + +
      +
      -

      +

      {original ? "비용 수정" : "비용 추가"}

      -

      {original ? expenseTitle(original) : "제목과 결제 정보를 입력해주세요."}

      +

      {original ? expenseTitle(original) : "제목과 결제 정보를 입력해주세요."}

      -
      -
      +
      +
      -
      + {/* 통화 이름이 숨는 조건(ExpenseCurrencyPicker의 mobile·max-sm)에서는 통화 칸을 코드 3글자 + ▼ 폭으로 줄여 금액 칸을 넓힌다 */} +
      {context.memberStatus === "success" ? ( -
      +
      )}
      -
      +
      diff --git a/src/components/expenses/ExpenseSelect.tsx b/src/components/expenses/ExpenseSelect.tsx index c9b49189..64b07bdc 100644 --- a/src/components/expenses/ExpenseSelect.tsx +++ b/src/components/expenses/ExpenseSelect.tsx @@ -40,12 +40,7 @@ export function ExpenseSelect({ const activeIndex = Math.min(active, options.length - 1); const selected = options.find((option) => option.value === value); function show() { - setSheet( - mobileSheet && - (isMobileDevice || - (typeof window !== "undefined" && - window.matchMedia?.("(max-width: 639px)").matches)), - ); + setSheet(mobileSheet && isMobileDevice); setActive( Math.max( 0, diff --git a/src/components/layout/HeaderMemberPreview.tsx b/src/components/layout/HeaderMemberPreview.tsx index 6ee2033d..2d5a4047 100644 --- a/src/components/layout/HeaderMemberPreview.tsx +++ b/src/components/layout/HeaderMemberPreview.tsx @@ -2,12 +2,11 @@ import { useEffect, useRef, useState } from "react"; -import { AddMemberPanel } from "@/app/(main)/member-settings/_components/AddMemberPanel"; import { JoinRequestsSection } from "@/app/(main)/member-settings/_components/JoinRequestsSection"; import { MemberList } from "@/app/(main)/member-settings/_components/MemberList"; import { useMemberActions } from "@/app/(main)/member-settings/_components/useMemberActions"; import { ChevronDownIcon, SendIcon } from "@/assets/icons"; -import { SettingsDialog } from "@/components/settings/SettingsDialog"; +import { InviteRoomDialog } from "@/components/rooms/InviteRoomDialog"; import { UserAvatar } from "@/components/user/UserAvatar"; import { useHostJoinRequestsBadgeCount } from "@/hooks/useHostJoinRequestsBadgeCount"; import { selectHeaderMemberPreview } from "@/lib/rooms/member-preview"; @@ -165,21 +164,15 @@ export function HeaderMemberPreview() { {inviteOpen && roomId ? ( - setInviteOpen(false)}> -

      링크를 공유해 함께 여행을 계획해 보세요.

      -
      - -
      -
      + setInviteOpen(false)} + /> ) : null} {dialogs} diff --git a/src/components/layout/LeftSection.tsx b/src/components/layout/LeftSection.tsx index 91ded288..7b9d68f0 100644 --- a/src/components/layout/LeftSection.tsx +++ b/src/components/layout/LeftSection.tsx @@ -1,7 +1,5 @@ "use client"; -import { motion } from "framer-motion"; - import { useMobileView } from "@/contexts/MobileViewContext"; import { useMainChromeLayoutWidth } from "@/contexts/MainChromeLayoutWidthContext"; @@ -16,22 +14,20 @@ export default function LeftSection({ leftSectionAnimateMinWidth, layoutTransition, } = useMainChromeLayoutWidth(); + const timing = `${layoutTransition.duration}s cubic-bezier(${layoutTransition.ease.join(",")})`; return ( - {children} - + ); } diff --git a/src/components/layout/MainLayoutChrome.tsx b/src/components/layout/MainLayoutChrome.tsx index 0a1b4653..de27124f 100644 --- a/src/components/layout/MainLayoutChrome.tsx +++ b/src/components/layout/MainLayoutChrome.tsx @@ -75,22 +75,25 @@ export function MainLayoutChrome({ children }: { children: ReactNode }) { return (
      - - - {isMobileDevice && (pathname === "/cost" || isPackingRoute) ? : null} -
      - {!isMobileDevice ? : null} - - {legacyViewPath ? null : children} - -
      - -
      + {/* 좌측 영역과 지도의 최소 폭 합보다 화면이 좁으면 페이지 대신 이 안에서 가로 스크롤한다(떠 있는 채팅은 화면 오른쪽에 그대로 둔다) */} +
      + + + {isMobileDevice && (pathname === "/cost" || isPackingRoute) ? : null} +
      + {!isMobileDevice ? : null} + + {legacyViewPath ? null : children} + +
      + +
      - {keepDesktopMap ?
      {!isMobileDevice && chatState === "minimized" ? : null}
      diff --git a/src/components/map/MapWithDetailPanel.tsx b/src/components/map/MapWithDetailPanel.tsx index 22f52b4c..8c2ad120 100644 --- a/src/components/map/MapWithDetailPanel.tsx +++ b/src/components/map/MapWithDetailPanel.tsx @@ -6,6 +6,7 @@ import { PlaceDetailPanel } from "@/components/place/PlaceDetailPanel"; import { MapToolbarLayoutProvider } from "@/contexts/MapToolbarLayoutContext"; import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; import { useMapDiscoverToolbarOffset } from "@/hooks/useMapDiscoverToolbarOffset"; +import { MAIN_MAP_MIN_WIDTH_PX } from "@/lib/layout-tokens"; import { cn } from "@/lib/utils"; import Map, { type MapRouteView } from "./Map"; @@ -36,13 +37,15 @@ export function MapWithDetailPanel({ const { setToolbarRef, panelTopPx } = useMapDiscoverToolbarOffset(mapSectionEl); + const visibleStyle = mobileInline ? undefined : { minWidth: MAIN_MAP_MIN_WIDTH_PX }; + return (