Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a15e249
docs(design):AI 하네스에 디자인 시스템 업데이트
Oct 12, 2026
54109a7
chore(icon):icon,test 추가
Oct 12, 2026
0ef38f9
feat(expenses): 일정 카드·일차 비용을 원화 합계 하나로 표시
Oct 12, 2026
ca02314
feat(card):장소 카드·메모 카드 디자인
Oct 12, 2026
c50b514
feat(plan):일차 헤더·비용 줄
Oct 12, 2026
f2ae142
feat(plan): Google 길찾기 링크
Oct 12, 2026
5ea899d
feat(plan): 페이지 여백·설명 문구
Oct 12, 2026
fc9112c
fix(cost): 모바일 바텀시트가 화면 폭을 다 채우도록 수정
Oct 13, 2026
bf50b1c
fix(home): 모바일에서 화면이 넓으면 방 목록을 여러 열로 표시
Oct 13, 2026
6a90dca
fix(sheet): 모바일 바텀시트 닫기·안전영역·애니메이션과 디자인을 기준 시트에 맞춤
Oct 13, 2026
9cf95e0
fix(layout): PC 좌측 패널·지도 최소 폭을 두고 좁은 화면은 가로 스크롤
Oct 13, 2026
ff3fc0b
fix(plan): 경로 조회 실패를 '경로 조회 오류'로 표시하고 렌더링 경고 제거
Oct 13, 2026
e04adb9
fix(sheet): 바텀시트 전환 기준을 화면 폭 대신 모바일 기기로 통일
Oct 13, 2026
9b03181
fix(layout): 가계부·준비물도 PC 최소 폭을 두고 준비물 상세가 열리면 목록 400 유지
Oct 13, 2026
82bd69e
fix(invite): 홈 방 카드의 여행 초대 모달을 헤더 초대하기 모달로 통일
Oct 13, 2026
3dfcc8d
fix(expenses): 통화 이름이 숨을 때 통화 칸을 코드 3글자 폭으로 줄여 금액 칸 확보 - 모달 너비 최솟값 결정사항
Oct 13, 2026
2d9151c
fix(responsive): Sonar 지적과 비용 시트 배경 제스처 정리
minbros Oct 13, 2026
e85a402
fix(invite): 공통 초대 모달의 조회 오류 복구와 테스트 정리
minbros Oct 13, 2026
a3e924f
merge(responsive): dev 병합 및 비용 표시 충돌 해결
minbros Oct 14, 2026
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
5 changes: 3 additions & 2 deletions src/app/(main)/map/_components/MobileRoutePlaceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
>
<span aria-hidden className="h-[3px] w-10 rounded-full bg-icon-disabled" />
<span aria-hidden className="h-1 w-9 rounded-[2px] bg-border-subtle" />
</button>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">{children}</div>
</div>
Expand Down
7 changes: 4 additions & 3 deletions src/app/(main)/member-settings/_components/AddMemberPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ function InviteFallback({ canIssue, inviteCode, isRoomDetailLoading, isRoomDetai
return <LoadingIndicator label={isRoomDetailLoading ? "방 정보 불러오는 중" : "초대 링크 발급 중"} />;
}
let message = "발급에 실패했어요. 아래에서 재발급을 눌러 주세요.";
if (!canIssue) message = "초대 링크를 사용할 수 없어요. 방 정보를 다시 확인해 주세요.";
else if (isRoomDetailError || inviteCode === undefined) message = "방 정보를 불러오지 못했어요. 아래에서 재발급을 눌러 주세요.";
return <span className="truncate text-body-s-regular mobile:text-body-xs-regular text-text-subtle">{message}</span>;
if (isRoomDetailError) message = "방 정보를 불러오지 못했어요.";
else if (!canIssue) message = "초대 링크를 사용할 수 없어요. 방 정보를 다시 확인해 주세요.";
else if (inviteCode === undefined) message = "방 정보를 불러오지 못했어요. 아래에서 재발급을 눌러 주세요.";
return <span role={isRoomDetailError ? "alert" : undefined} className="truncate text-body-s-regular mobile:text-body-xs-regular text-text-subtle">{message}</span>;
}

export function AddMemberPanel({
Expand Down
28 changes: 16 additions & 12 deletions src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -226,35 +226,39 @@ export function AddFromBookmarkModal({
return (
<div
role="presentation"
className="fixed inset-0 z-[60] flex flex-col justify-end bg-black/40 md:justify-center md:px-4"
onMouseDown={(ev) => {
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"
>
{/* 딤은 시트와 따로 둬서 기준 시트처럼 딤만 서서히 나타나고 시트는 아래에서 올라온다 */}
<div
aria-hidden
className="absolute inset-0 bg-black/40 mobile:bg-[#0f1724]/35 mobile:animate-in mobile:fade-in-0 mobile:duration-200"
onMouseDown={onClose}
/>
<div
style={sheetDrag.surfaceStyle}
role="dialog"
aria-modal="true"
aria-labelledby="add-from-bookmark-modal-title"
onMouseDown={(e) => 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)]",
)}
>
<BottomSheetDragHandle drag={sheetDrag} className="max-md:touch-none max-md:select-none max-md:cursor-grab">
<BottomSheetDragHandle drag={sheetDrag}>
<div
aria-hidden
className="mx-auto mt-3 h-1 w-10 shrink-0 rounded-full bg-black/15 md:hidden"
className="mx-auto mt-[11px] hidden h-1 w-9 shrink-0 rounded-[2px] bg-border-subtle mobile:block"
/>

<div className="shrink-0 px-5 pb-2 pt-3">
<h2
id="add-from-bookmark-modal-title"
className="text-title-l mobile:text-title-m font-semibold text-neutral-900"
className="text-title-l mobile:text-title-m font-semibold mobile:font-bold text-neutral-900"
>
북마크에서 추가
</h2>
Expand Down
3 changes: 2 additions & 1 deletion src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={<CoinIcon size={16} className={PLAN_PLACE_CARD_TW.actionIcon} />}
/>
)}
Expand Down
22 changes: 1 addition & 21 deletions src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -275,6 +259,7 @@ export function PlanTravelTime({
selectedMode,
serverMode,
mutateTravelMode,
setSelectedMode,
],
);

Expand Down Expand Up @@ -318,11 +303,6 @@ export function PlanTravelTime({
effectiveMode={selectedMode}
showUnknownOption={false}
modeRaw=""
renderWarning={
mapRouteRenderStatus === "unavailable"
? "해당 경로는 렌더링할 수 없어요."
: undefined
}
googleMapsDirectionsUrl={googleMapsDirectionsUrl ?? undefined}
subtleDirectionsLink={!isMobileDevice}
onSelectTravelMode={handleSelectTravelMode}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@ export type TravelDirectionsCardProps = {
effectiveMode: ScheduleTravelModeValue;
showUnknownOption: boolean;
modeRaw: string;
/** 지도 polyline을 만들 수 없을 때 요약 우측에 표시 */
renderWarning?: string;
googleMapsDirectionsUrl?: string;
/** PC 일정 — 구간마다 반복되는 길찾기 링크가 튀지 않게 회색 12px로 낮춘다 */
subtleDirectionsLink?: boolean;
Expand All @@ -63,7 +61,6 @@ export function TravelDirectionsCard({
effectiveMode,
showUnknownOption,
modeRaw,
renderWarning,
googleMapsDirectionsUrl,
subtleDirectionsLink = false,
readOnly = false,
Expand Down Expand Up @@ -124,15 +121,6 @@ export function TravelDirectionsCard({
</a>
) : null}

{renderWarning ? (
<span
role="status"
title={renderWarning}
className="flex h-10 min-w-0 flex-1 items-center overflow-hidden px-2 text-body-xs-emphasis font-medium text-primary"
>
<span className="truncate">{renderWarning}</span>
</span>
) : null}
</div>

{menuOpen ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function TravelRouteSummaryLine({
<LoadingIndicator label="이동 시간 불러오는 중" size={14} />
);
}
if (isError) return "이동 시간을 불러오지 못했어요";
if (isError) return <span className="text-status-negative">경로 조회 오류</span>;
if (routeUnavailable) return "이동 안내 없음";
return "다음 장소로 이동";
}
40 changes: 12 additions & 28 deletions src/app/home/_components/InviteRoomModal.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,26 @@
"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;
}) {
const { data, isLoading, isError, refetch } = useRoomDetail(room.id);

return (
<SettingsDialog title="여행 초대" onClose={onClose}>
<p className="mb-5 break-words text-body-m-emphasis text-text-subtle">{room.title}</p>
{isLoading ? (
<LoadingIndicator label="초대 링크 불러오는 중" className="w-full py-8" />
) : isError || !data ? (
<div role="alert" className="space-y-4 py-6 text-center">
<p className="text-body-m-regular text-text-subtle">방 정보를 불러오지 못했어요.</p>
<button type="button" onClick={() => void refetch()} className="rounded-[12px] bg-primary px-5 py-3 text-label-l-emphasis text-text-inverse">다시 시도</button>
</div>
) : !isRoomMemberRole(data.role) ? (
<p className="py-8 text-center text-body-m-regular text-text-subtle">여행 멤버만 초대 링크를 공유할 수 있어요.</p>
) : (
<AddMemberPanel
key={room.id}
roomId={room.id}
inviteCode={data.inviteCode}
memberCount={data.memberCount}
role={data.role}
isRoomDetailLoading={false}
isRoomDetailError={false}
/>
)}
</SettingsDialog>
<InviteRoomDialog
roomId={room.id}
inviteCode={data?.inviteCode}
memberCount={data?.memberCount}
role={data?.role}
isRoomDetailLoading={isLoading}
isRoomDetailError={isError}
onRetryRoomDetail={() => { void refetch(); }}
onClose={onClose}
/>
);
}
20 changes: 18 additions & 2 deletions src/app/home/_components/RoomCardMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("복사");
Expand Down Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion src/app/home/_components/RoomGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function RoomGrid({
}

return (
<ul className="grid grid-cols-1 gap-x-4 gap-y-12 sm:grid-cols-2 lg:grid-cols-3 mobile:grid-cols-1 mobile:gap-y-4">
<ul className="grid grid-cols-1 gap-x-4 gap-y-12 sm:grid-cols-2 lg:grid-cols-3 mobile:gap-y-4">
{rooms.map((room, index) => (
<li key={room.id} className="min-w-0">
<RoomCard
Expand Down
107 changes: 107 additions & 0 deletions src/components/expenses/ExpenseBackdrop.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ExpenseEditor } from "./ExpenseEditor";
import { ExpenseScopePanel } from "./ExpenseScopePanel";

const state = vi.hoisted(() => ({ 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"
? <ExpenseEditor initial={{}} onClose={close} />
: <ExpenseScopePanel scope={{ scheduleId: 2, label: "1일차" }} roomId="r" expenses={[]}
isPending={false} isError={false} canManage busy={state.busy} onAdd={() => {}}
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();
});
});
}
Loading