diff --git a/Design.md b/Design.md index ed96d832..2f0bf543 100644 --- a/Design.md +++ b/Design.md @@ -21,6 +21,22 @@ - 특정 페이지 전용 컴포넌트는 `src/app/**/_components/`에 둔다 - 없으면 Tailwind로 직접 작업한다 +### 1-4. Figma 디자인 시스템 +[Design System](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System) 파일은 확정된 내용만 모아 둔 곳이다. 퍼블리싱할 때 해당 페이지의 컴포넌트·토큰을 기준으로 맞춘다. + +- 00 [Foundation](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=161-9) — Color(Primitive·Semantic), Typography, Icon, Spacing, Shadow, Radius, Grid, Logo +- 01 [Button](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5002-3575) — Button, Button Base (Size·State·Layout) +- 02 [Text Field](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5002-4296) — Text Field Emphasis, Textarea Field, Search Bar, Input Base +- 03·04·05 [Header · Footer · Side Bar](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5001-3046) — Header(Login·Logout), Footer, Side Bar +- 06 [Navigation](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5002-4797) — Bottom Navigation +- 07·08 [Select Field · Dropdown](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5030-540) — Select Field, Select Field_Emphasis, Dropdown, Item +- 09·10 [Date Picker · Time Picker](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5045-885) — Date Picker, Date Cell, Time Picker, Time Picker Modal, Period +- 11 [Tabs](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5138-2632) — Tabs Line, Tabs Chip, Tab Item +- 12 [Toast](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5285-4979) — Toast +- 13 [Alert Dialog](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5619-553) — Alert Dialog +- 14·15 [Chip · Badge](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5624-159) — Chip(Solid·Outline), Badge(Weak·Solid·Outline, 톤 6종) +- [Update Log](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5635-11) — 디자인 시스템 변경 기록 + --- ## 2. 아이콘의 사용 diff --git a/docs/analytics/amplitude-tracking-plan.md b/docs/analytics/amplitude-tracking-plan.md index 00529350..5638293f 100644 --- a/docs/analytics/amplitude-tracking-plan.md +++ b/docs/analytics/amplitude-tracking-plan.md @@ -53,6 +53,8 @@ | `packing_item_checked` | 본인 체크 요청 응답으로 미완료 → 완료가 확정됐을 때 | `room_id` (Amplitude만) | 없음 | Amplitude | | `packing_item_unchecked` | 본인 체크 요청 응답으로 완료 → 미완료가 확정됐을 때 | `room_id` (Amplitude만) | 없음 | Amplitude | +웹 전체 검색은 결과 핀만 표시하며, 핀의 Text Search 미리보기 말풍선은 `view_place`로 세지 않는다. 말풍선의 화살표를 눌러 기존 상세 패널의 데이터가 표시될 때 `view_place`를 기록하고 원래 검색 결과 순서의 `rank_bucket`을 유지한다. 모바일 결과 카드·상세 흐름과 자동완성 특정 장소 선택은 기존 기준을 유지한다. + 준비물 체크는 상태가 실제로 바뀐 경우만 기록한다. 자동 목록 초기화, 읽기, 낙관적 변경, 실패·불확실한 응답, 중복·차단 요청은 포함하지 않는다. 가계부/준비물 쓰기는 성공 응답 뒤 후속 조회가 실패해도 집계하되, 계정·방·권한이 바뀐 뒤의 응답을 다른 사용자에게 귀속하지 않는다. 브로드캐스트 수신자의 이벤트는 만들지 않는다. 정산 요약 조회는 버튼 클릭이나 로딩·오류가 아니라 성공 데이터 표시를 뜻한다. 열린 동안 재렌더·재조회로 중복하지 않으며 닫았다 다시 열면 새 조회다. 송금·정산 완료 이벤트는 구현하지 않는다. `chat_message_sent`도 서버 저장·AI 응답 성공으로 해석하지 않는다. diff --git a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx index 28855517..1bc73da3 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx @@ -13,8 +13,9 @@ import { BookmarkFolderDetailHeader } from "./BookmarkFolderDetailHeader"; import { BookmarkListSkeleton } from "./BookmarkListSkeleton"; import { BookmarkPlaceRow } from "./BookmarkPlaceRow"; +// 장소 추가·삭제 시 데스크톱 목록 행의 너비가 흔들리지 않게 한다. const SCROLLBAR = - "min-h-0 flex-1 overflow-y-auto [scrollbar-color:rgba(0,0,0,0.2)_transparent]"; + "min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable] mobile:[scrollbar-gutter:auto] [scrollbar-color:rgba(0,0,0,0.2)_transparent]"; function trimGooglePlaceId(raw: unknown): string { return typeof raw === "string" ? raw.trim() : ""; diff --git a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx index 9ea90a49..e2bee4b7 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx @@ -19,7 +19,7 @@ type MobileRoutePlaceCardProps = Readonly<{ badgeColor: string; /** 방금 추가된 카드 — 잠깐 강조한다 */ highlighted: boolean; - /** `45,000 KRW 외 1건` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */ + /** 대표 통화 원금액 요약 `30 USD 외 1건` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */ expenseSummary: string | null; /** 비용을 추가할 수 있는지(비용 권한) */ canAddExpense: boolean; @@ -146,11 +146,12 @@ 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}
+
{children}
); } diff --git a/src/app/(main)/map/_components/MobileRoutePlaceDetail.error.test.tsx b/src/app/(main)/map/_components/MobileRoutePlaceDetail.error.test.tsx new file mode 100644 index 00000000..2c87683f --- /dev/null +++ b/src/app/(main)/map/_components/MobileRoutePlaceDetail.error.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; +import { MobileRoutePlaceDetail } from "./MobileRoutePlaceDetail"; +const boundary = vi.hoisted(() => ({ + tabs: vi.fn(), + retry: vi.fn(), + track: vi.fn(), +})); +vi.mock("@/hooks/useAnalyticsRoomId", () => ({ + useAnalyticsRoomId: () => "room", +})); +vi.mock("@/components/place/usePlaceDetailData", () => ({ + usePlaceDetailData: () => ({ + isLoading: false, + isError: true, + refetch: boundary.retry, + }), +})); +vi.mock("@/components/place/HeroSection", () => ({ + HeroImage: () => null, + HeroSkeleton: () => null, +})); +vi.mock("@/components/place/PlaceDetailTabs", () => ({ + PlaceDetailTabs: (props: unknown) => { + boundary.tabs(props); + return

리뷰 없음

; + }, +})); +vi.mock("@/lib/analytics/track", () => ({ + AnalyticsEvents: { viewPlace: "view_place" }, + trackAnalyticsEvent: boundary.track, +})); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +it("failed full Details is retryable and never represented as no reviews or a successful view", async () => { + const host = document.createElement("div"); + const root = createRoot(host); + await act(async () => + root.render( + , + ), + ); + expect(boundary.tabs).not.toHaveBeenCalled(); + expect(boundary.track).not.toHaveBeenCalled(); + expect(host.querySelector('[role="alert"]')).not.toBeNull(); + await act(async () => host.querySelector("button")!.click()); + expect(boundary.retry).toHaveBeenCalledTimes(1); + await act(async () => root.unmount()); +}); diff --git a/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx index ecc894c9..b42911e3 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx @@ -8,6 +8,7 @@ import { PlaceDetailTabs } from "@/components/place/PlaceDetailTabs"; import { usePlaceDetailData } from "@/components/place/usePlaceDetailData"; import { AnalyticsEvents, trackAnalyticsEvent, type AnalyticsSource } from "@/lib/analytics/track"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; +import type { SearchRankBucket } from "@/lib/analytics/context"; import type { PlanPlace } from "@/lib/plan/types"; /** @@ -19,31 +20,71 @@ export function MobileRoutePlaceDetail({ place, active, analyticsSource = "plan", + analyticsRankBucket, }: Readonly<{ place: Pick; active: boolean; analyticsSource?: AnalyticsSource; + analyticsRankBucket?: SearchRankBucket; }>) { const analyticsRoomId = useAnalyticsRoomId(); const rawId = place.googlePlaceId?.trim() ?? ""; const googlePlaceId = rawId ? normalizeGooglePlaceResourceId(rawId) : undefined; - const { data: detail, isLoading } = usePlaceDetailData(active ? googlePlaceId : undefined); + const { data: detail, isLoading, isError, refetch } = usePlaceDetailData(active ? googlePlaceId : undefined); const category = detail?.primaryTypeDisplayName ?? place.primaryTypeDisplayName ?? ""; // 일정 상세를 본 것으로 한 번만 기록한다(장소 상세 패널과 같은 이벤트) const trackedPlaceIdRef = useRef(null); useEffect(() => { - if (!active || !googlePlaceId || isLoading || trackedPlaceIdRef.current === googlePlaceId) return; + if (!active || !googlePlaceId || isLoading || isError || trackedPlaceIdRef.current === googlePlaceId) return; trackedPlaceIdRef.current = googlePlaceId; trackAnalyticsEvent(AnalyticsEvents.viewPlace, { place_category: category, interaction_source: analyticsSource, + ...(analyticsRankBucket ? { rank_bucket: analyticsRankBucket } : {}), ...(analyticsRoomId ? { room_id: analyticsRoomId } : {}), }); - }, [active, analyticsSource, analyticsRoomId, category, googlePlaceId, isLoading]); + }, [active, analyticsSource, analyticsRankBucket, analyticsRoomId, category, googlePlaceId, isLoading, isError]); const showSkeleton = !active || (isLoading && !detail); + function renderDetail() { + if (isError && !detail && active) return ( +
+

장소 정보를 불러오지 못했어요.

+ +
+ ); + if (showSkeleton) return ( + + + + + ); + return ( + // 장소 상세 시트와 같은 홈·리뷰 탭 — 같은 상세 응답을 쓰므로 추가 호출이 없다 +
+ +
+ ); + } + return (
@@ -54,33 +95,7 @@ export function MobileRoutePlaceDetail({ )}
- {showSkeleton ? ( - - - - - ) : ( - // 장소 상세 시트와 같은 홈·리뷰 탭 — 같은 상세 응답을 쓰므로 추가 호출이 없다 -
- -
- )} + {renderDetail()}
); diff --git a/src/app/(main)/map/_components/useMobileRouteView.test.tsx b/src/app/(main)/map/_components/useMobileRouteView.test.tsx index 996ec086..3db17c64 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.test.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { act, useEffect } from "react"; +import { act, useEffect, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { expect, it, vi } from "vitest"; @@ -10,6 +10,9 @@ const fixtures = vi.hoisted(() => ({ { id: "item-101", itemId: 101, title: "첫 장소", location: { lat: 37.5, lng: 127 } }, { id: "item-102", itemId: 102, title: "먼 장소", location: { lat: 37.7, lng: 127.2 } }, ], + expenses: [] as import("@/lib/api/rooms/expenses").Expense[], + open: vi.fn(), + openScope: vi.fn(), setSelectedPlace: vi.fn(), router: { replace: vi.fn() }, })); @@ -22,7 +25,7 @@ vi.mock("@/hooks/useRooms", () => ({ })); vi.mock("@/hooks/usePrefetchScheduleRoutes", () => ({ usePrefetchScheduleRoutes: () => true })); vi.mock("@/components/expenses/ExpenseProvider", () => ({ - useExpenseContext: () => ({ list: { data: [] }, canManage: true }), + useExpenseContext: () => ({ list: { data: fixtures.expenses }, canManage: true, open: fixtures.open, openScope: fixtures.openScope }), })); vi.mock("@/contexts/SelectedPlaceContext", () => ({ useSelectedPlace: () => ({ setSelectedPlace: fixtures.setSelectedPlace }), @@ -32,20 +35,17 @@ vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ readSchedulePlanPlacesFromCach vi.mock("@/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch", () => ({ useMobileAddPlaceSearch: () => ({ recentlyAddedItemId: null, screen: null }), })); -vi.mock("@/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets", () => ({ +vi.mock("@/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets", async (importOriginal) => ({ + ...await importOriginal(), MobilePlanPlaceSheets: () => null, - openPlaceExpenses: vi.fn(), })); vi.mock("@/components/map/MapCloseButton", () => ({ MapCloseButton: () => null })); vi.mock("./MobileRouteCandidateCard", () => ({ MobileRouteCandidateCard: () => null })); -vi.mock("./MobileRouteCardCarousel", () => ({ MobileRouteCardCarousel: () => null })); -vi.mock("./MobileRoutePlaceDetail", () => ({ MobileRoutePlaceDetail: () => null })); -vi.mock("./MobileRoutePlaceCard", () => ({ - MobileRouteEmptyDayCard: () => null, - MobileRouteMessageCard: () => null, - MobileRoutePlaceCard: () => null, - MobileRoutePlaceCardSkeleton: () => null, +vi.mock("./MobileRouteCardCarousel", () => ({ + MobileRouteCardCarousel: ({ cards }: { cards: { key: string; node: ReactNode }[] }) => <>{cards.map(card =>
{card.node}
)}, })); +vi.mock("./MobileRoutePlaceDetail", () => ({ MobileRoutePlaceDetail: () => null })); + import { readMapRouteParams } from "@/lib/mobile-view"; import { scheduleItemsQueryKey } from "@/lib/query-keys"; @@ -85,3 +85,70 @@ it.each([ host.remove(); } }); + + +import { MobilePlanItinerary } from "@/app/(main)/plan/_components/mobile/MobilePlanItinerary"; +import { MobilePlanDragProvider } from "@/app/(main)/plan/_components/mobile/mobilePlanDrag"; +vi.mock("@/app/(main)/plan/_components/travel-time/PlanTravelTime", () => ({ PlanTravelTime: () => null })); + +it.each([ + { rows: [["KRW", "1000"]], label: "1,000 KRW" }, + { rows: [["JPY", "3600"]], label: "3,600 JPY" }, + { rows: [["USD", "10"], ["USD", "20"], ["KRW", "1000"]], label: "30 USD 외 1건" }, + { rows: [["USD", "10.10"], ["USD", "20.20"]], label: "30.30 USD" }, + { rows: [["JPY", "0"]], label: "0 JPY" }, + { rows: [["USD", "12345678901234567890.12"], ["EUR", "1"]], label: "12,345,678,901,234,567,890.12 USD 외 1건" }, + { rows: [], label: null }, +].flatMap(testCase => ["itinerary", "route"].map(mode => ({ ...testCase, mode }))))("$mode shows $label with scoped list/empty-add behavior", async ({ rows, label, mode }) => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const originalScrollTo = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTo"); + Object.defineProperty(HTMLElement.prototype, "scrollTo", { configurable: true, value: vi.fn() }); + fixtures.expenses = rows.map(([currency, totalAmount], id) => ({ + id, version: 1, expenseGroup: "TRIP_DAY", scheduleId: 10, scheduleItemId: 101, + currency, totalAmount, category: "OTHER", memo: null, name: null, + payerUserIds: [1], participantUserIds: [1], createdAt: "", updatedAt: "", + })); + // Another place must not affect either summary or empty/add decisions. + fixtures.expenses.push({ id: 100, version: 1, expenseGroup: "TRIP_DAY", scheduleId: 10, scheduleItemId: 102, currency: "KRW", totalAmount: "999999", category: "OTHER", memo: null, name: null, payerUserIds: [1], participantUserIds: [1], createdAt: "", updatedAt: "" }); + fixtures.open.mockClear(); + fixtures.openScope.mockClear(); + window.history.replaceState(null, "", "/map?view=route&day=1&item=101"); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(scheduleItemsQueryKey("room-1", 10), fixtures.places); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + function Route() { return useMobileRouteView({ active: true, day: 1, initialItemId: 101 }).overlay; } + const drag = { editing: false, dragDisabled: false, pending: null, dropTargetScheduleId: null, + registerList: vi.fn(), registerRow: vi.fn(), registerPlaces: vi.fn(), startDrag: vi.fn(), rowStyle: () => undefined }; + try { + await act(async () => root.render( + {mode === "route" ? : } + )); + const button = host.querySelector(`button[aria-label="첫 장소 비용 ${label ? "보기" : "추가"}"]`); + if (label) { + expect(button?.textContent).toContain(label); + const descriptionId = button?.getAttribute("aria-describedby"); + expect(descriptionId).toBeTruthy(); + expect(document.getElementById(descriptionId!)?.textContent).toBe(label); + const expenseButtons = [...host.querySelectorAll("button[aria-describedby]")]; + expect(new Set(expenseButtons.map(node => node.getAttribute("aria-describedby"))).size).toBe(expenseButtons.length); + await act(async () => button!.click()); + expect(fixtures.openScope).toHaveBeenLastCalledWith({ scheduleId: 10, scheduleItemId: 101, label: "첫 장소" }); + expect(fixtures.open).not.toHaveBeenCalled(); + } else if (mode === "route") { + expect(button).not.toBeNull(); + await act(async () => button!.click()); + expect(fixtures.open).toHaveBeenCalledWith({ scheduleId: 10, scheduleItemId: 101 }); + expect(fixtures.openScope).not.toHaveBeenCalled(); + } else { + // Itinerary keeps empty expense rows hidden; its action sheet owns adding. + expect(button).toBeNull(); + } + } finally { + await act(async () => root.unmount()); + queryClient.clear(); host.remove(); fixtures.expenses = []; + if (originalScrollTo) Object.defineProperty(HTMLElement.prototype, "scrollTo", originalScrollTo); + else Reflect.deleteProperty(HTMLElement.prototype, "scrollTo"); + } +}); diff --git a/src/app/(main)/map/_components/useMobileRouteView.tsx b/src/app/(main)/map/_components/useMobileRouteView.tsx index 38d44d87..eadc8e74 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.tsx @@ -16,7 +16,7 @@ import { expensesInScope } from "@/lib/expenses/expense-scope"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; import { buildMapRouteHref, MOBILE_MAP_PATH } from "@/lib/mobile-view"; import { insertAnchorAfterItem } from "@/lib/plan/insertPosition"; -import { formatMonthDayKo, summarizeExpensesForMobile } from "@/lib/plan/mobilePlanFormat"; +import { formatMonthDayKo, summarizePlaceExpenses } from "@/lib/plan/mobilePlanFormat"; import { scheduleIdsToRouteColors, sortedScheduleIdsForRouteColors, @@ -401,7 +401,7 @@ export function useMobileRouteView({ orderNumber={entry.orderNumber} badgeColor={dayColor(entry.scheduleId)} highlighted={addPlace.recentlyAddedItemId === entry.itemId} - expenseSummary={summarizeExpensesForMobile(itemExpenses(entry.scheduleId, entry.itemId))} + expenseSummary={summarizePlaceExpenses(itemExpenses(entry.scheduleId, entry.itemId))} canAddExpense={expenses.canManage} expanded={isExpanded} onExpand={() => { @@ -459,34 +459,34 @@ export function useMobileRouteView({
{/* 펼치면 X·일차 탭은 사라지고 카드가 그 자리까지 커진다 */}
-
+
+
-
{/* 접힘: 가장 긴 카드에 맞춰 모든 카드가 같은 높이 / 펼침: 지도의 70% */}
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/PlanDaySection.test.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx index a285e7ec..c1d592ed 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx @@ -5,13 +5,13 @@ import { describe, expect, it } from "vitest"; import { PlanDaySection } from "./PlanDaySection"; describe("PlanDaySection", () => { - it("펼침 토글 아이콘을 제목 왼쪽에 배치한다", () => { + it("펼침 토글 아이콘을 날짜·일차 배지 뒤에 배치한다", () => { const html = renderToStaticMarkup( createElement( PlanDaySection, { title: "1일차", - subtitle: "7월 16일 (목)", + subtitle: "07.16(목)", }, createElement("div", null, "일정"), ), @@ -24,8 +24,11 @@ describe("PlanDaySection", () => { expect(triggerMarkup).toBeDefined(); expect(triggerMarkup).toContain('class="shrink-0 text-dark-gray"'); expect(triggerMarkup).not.toContain("ml-auto"); - expect(triggerMarkup!.indexOf("lucide-chevron-up")).toBeLessThan( - triggerMarkup!.indexOf("7월 16일 (목)"), + expect(triggerMarkup!.indexOf("lucide-chevron-up")).toBeGreaterThan( + triggerMarkup!.indexOf("1일차"), + ); + expect(triggerMarkup!.indexOf("07.16(목)")).toBeLessThan( + triggerMarkup!.indexOf("1일차"), ); expect(html).not.toContain("absolute left-1/2"); }); diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx index e72ba82d..b13f43ff 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx @@ -170,7 +170,7 @@ export function PlanDaySection({
+
+

+ {subtitle || title} +

+ {subtitle ? ( + // 디자인 시스템 Badge / Weak / Brand / md — 배경색은 시맨틱 토큰이 없어 원시 토큰을 쓴다 + + {title} + + ) : null} +
{expanded ? ( @@ -215,18 +227,6 @@ export function PlanDaySection({ )} -
-
-

- {subtitle || title} -

- {subtitle ? ( - - {title} - - ) : null} -
-
{showScheduleMenu ? (
@@ -315,7 +315,7 @@ export function PlanDaySection({
{summaryAction || trackedSid !== undefined ? (
{summaryAction}
@@ -328,7 +328,7 @@ export function PlanDaySection({ toggleRouteVisible(); }} aria-checked={routeVisible} - className="flex min-h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 text-label-s-regular text-dark-gray transition-colors hover:bg-bubble-gray/60 focus-visible:outline-2 focus-visible:outline-primary" + className="flex min-h-8 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 text-label-xs-regular text-text-subtle transition-colors hover:bg-bubble-gray/60 focus-visible:outline-2 focus-visible:outline-primary" > 지도에 경로 표시