Skip to content
Merged
16 changes: 16 additions & 0 deletions Design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 아이콘의 사용
Expand Down
2 changes: 1 addition & 1 deletion src/app/(main)/map/_components/MobileRoutePlaceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type MobileRoutePlaceCardProps = Readonly<{
badgeColor: string;
/** 방금 추가된 카드 — 잠깐 강조한다 */
highlighted: boolean;
/** `45,000 KRW 외 1건` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */
/** 대표 통화 원금액 요약 `30 USD 외 1건` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */
expenseSummary: string | null;
/** 비용을 추가할 수 있는지(비용 권한) */
canAddExpense: boolean;
Expand Down
89 changes: 78 additions & 11 deletions src/app/(main)/map/_components/useMobileRouteView.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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() },
}));
Expand All @@ -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 }),
Expand All @@ -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<typeof import("@/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets")>(),
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 => <section key={card.key}>{card.node}</section>)}</>,
}));
vi.mock("./MobileRoutePlaceDetail", () => ({ MobileRoutePlaceDetail: () => null }));


import { readMapRouteParams } from "@/lib/mobile-view";
import { scheduleItemsQueryKey } from "@/lib/query-keys";
Expand Down Expand Up @@ -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(<QueryClientProvider client={queryClient}>
{mode === "route" ? <Route /> : <MobilePlanDragProvider value={drag}><MobilePlanItinerary roomId="room-1" scheduleId={10} onRequestAddPlace={vi.fn()} recentlyAddedItemId={null} onPlaceAdded={vi.fn()} /></MobilePlanDragProvider>}
</QueryClientProvider>));
const button = host.querySelector<HTMLButtonElement>(`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");
}
});
4 changes: 2 additions & 2 deletions src/app/(main)/map/_components/useMobileRouteView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "일정"),
),
Expand All @@ -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");
});
Expand Down
37 changes: 19 additions & 18 deletions src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export function PlanDaySection({
<div className="overflow-visible rounded-2xl">
<div
className={cn(
"relative flex w-full items-stretch gap-0.5 py-3.5",
"relative flex w-full items-stretch gap-0.5 pb-[13px] pt-3.5",
MAIN_CARD_INNER_PADDING_X_CLASS,
expanded ? "rounded-t-2xl" : "rounded-2xl",
)}
Expand All @@ -191,7 +191,8 @@ export function PlanDaySection({
if (e.key === " " || e.key === "Enter") e.preventDefault();
}}
className={cn(
"flex shrink-0 touch-none select-none self-center rounded-lg p-1.5 text-dark-gray",
// 그래버를 아래 비용 줄의 코인 아이콘과 같은 세로선(가로 중심)에 둔다
"-ml-[5px] mr-[3px] flex shrink-0 touch-none select-none self-center rounded-lg p-1.5 text-dark-gray",
dragDisabled
? "cursor-not-allowed opacity-40"
: "cursor-grab active:cursor-grabbing hover:bg-bubble-gray/60",
Expand All @@ -206,27 +207,26 @@ export function PlanDaySection({
aria-expanded={expanded}
aria-controls={`${panelId}-panel`}
onClick={toggle}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg text-left transition-colors hover:bg-bubble-gray/60"
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-lg text-left transition-colors hover:bg-bubble-gray/60"
>
<div className="flex min-w-0 items-center gap-1">
<h2 className="min-w-0 truncate text-title-s text-gray-900">
{subtitle || title}
</h2>
{subtitle ? (
// 디자인 시스템 Badge / Weak / Brand / md — 배경색은 시맨틱 토큰이 없어 원시 토큰을 쓴다
<span className="inline-flex h-5 shrink-0 items-center rounded bg-[var(--blue-50)] px-1.5 text-label-xs-regular text-primary">
{title}
</span>
) : null}
</div>
<span className="shrink-0 text-dark-gray" aria-hidden>
{expanded ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</span>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h2 className="min-w-0 truncate text-title-s font-semibold text-gray-900">
{subtitle || title}
</h2>
{subtitle ? (
<span className="inline-flex shrink-0 items-center rounded-md bg-sky-100 px-2 py-0.5 text-body-xs-emphasis font-semibold text-sky-700">
{title}
</span>
) : null}
</div>
</div>
</button>
{showScheduleMenu ? (
<div ref={menuRef} className="relative shrink-0 self-center">
Expand Down Expand Up @@ -315,7 +315,7 @@ export function PlanDaySection({
</div>
{summaryAction || trackedSid !== undefined ? (
<div className={cn(
"flex flex-wrap items-center justify-between gap-2 border-t border-dashed border-gray-border py-2",
"flex flex-wrap items-center justify-between gap-2 border-t border-dashed border-gray-border py-1",
MAIN_CARD_INNER_PADDING_X_CLASS,
)}>
<div className="min-w-0 flex-1">{summaryAction}</div>
Expand All @@ -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"
>
지도에 경로 표시
<span aria-hidden="true" className={cn(
Expand All @@ -353,9 +353,10 @@ export function PlanDaySection({
<div
className={cn(
cn(MAIN_CARD_INNER_PADDING_X_CLASS, "pb-4"),
// 비용·경로 표시 줄이 있으면 첫 카드 위 여백을 두지 않아 그 줄의 위아래 여백(4px)을 같게 한다
expanded
? summaryAction || trackedSid !== undefined
? "pt-1"
? null
: "border-t border-dashed border-gray-border pt-3"
: "pt-1",
)}
Expand Down
14 changes: 7 additions & 7 deletions src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use client";

import { Trash2 } from "lucide-react";

import { TrashIcon } from "@/assets/icons";
import { MemoIcon } from "@/components/icons";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
Expand Down Expand Up @@ -116,9 +115,10 @@ export function PlanItemMemoReadOnly({
if (!text.length) return null;

return (
<div className={PLAN_PLACE_CARD_TW.editorModule}>
<div className={PLAN_PLACE_CARD_TW.editorSideLabelWrapper}>
<MemoIcon className="h-6 w-6" />
// 아이콘·간격은 카드 액션 버튼(16px 아이콘, 6px 간격)과 맞추고, 글자 시작·높이·휴지통 위치는 디자인 값에 맞춘다
<div className={cn(PLAN_PLACE_CARD_TW.editorModule, "gap-1.5 py-[13px] pl-[19px]")}>
<div className={cn(PLAN_PLACE_CARD_TW.editorSideLabelWrapper, "pt-1")}>
<MemoIcon className="h-4 w-4" />
</div>
<p className="min-w-0 flex-1 whitespace-pre-wrap break-words text-body-s-regular mobile:text-body-xs-regular leading-relaxed text-gray-900">
{renderTextWithLinks(text, {
Expand All @@ -136,9 +136,9 @@ export function PlanItemMemoReadOnly({
e.stopPropagation();
onDelete();
}}
className="shrink-0 cursor-pointer self-start rounded-md p-1 text-dark-gray transition hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
className="-mr-[3px] shrink-0 cursor-pointer self-start rounded-md p-1 text-dark-gray transition hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-4 w-4" aria-hidden />
<TrashIcon size={16} />
</button>
) : null}
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) {
>
<PlanTravelTime
contentOnly
// 구간 줄을 카드 폭만큼 채워 Google 길찾기를 오른쪽 끝에 둔다
className="w-full"
roomId={roomId}
scheduleId={scheduleId}
segmentSourceItemId={place.itemId}
Expand Down
Loading