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/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx index 9ea90a49..98122c1d 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; 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..51e30d53 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={() => { 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" > 지도에 경로 표시