Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions Design.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@
export const MapPinIcon = createIcon(MapPinSvg, "MapPinIcon"); // 아래쪽 export 구역
```

### 2-1-1. Figma에 추가 요청할 아이콘
Figma 디자인 시스템 Icon에 없어 임시로 Tabler Icons(outline)에서 받아 쓰는 아이콘이다. 디자이너가 디자인 시스템에 추가하면 원본으로 바꾸고 이 목록에서 지운다.
- 파일 형식은 2-1의 규칙(24×24 viewBox, `currentColor`, stroke 1.8)에 맞춰 수정해 넣는다

| 아이콘 | 파일 | 출처 | 쓰는 곳 |
|---|---|---|---|
| 경로 보기 | `route.svg` (`RouteIcon`) | Tabler `route` | 모바일 지도 경로 보기 버튼 |
| 보내기(종이비행기) | `send.svg` (`SendIcon`) | Tabler `send` | 헤더 초대하기 (PR #220) |

### 2-2. 아이콘 사용
```tsx
import { MapPinIcon } from "@/assets/icons";
Expand Down
24 changes: 24 additions & 0 deletions src/app/(main)/chat/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";

vi.mock("@/contexts/MobileViewContext", () => ({ useMobileView: () => ({ isMobileDevice: false }) }));
vi.mock("@/components/chat", () => ({ ChatPanel: ({ inline }: { inline?: boolean }) => <div data-chat data-inline={inline ? "true" : "false"} /> }));

import ChatPage from "./page";
import { useChatPanelStore } from "@/stores/chat-panel-store";

it("뒤로가기·주소 입력으로 들어와도 최소화를 풀고 최대화 채팅을 그린다", async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
useChatPanelStore.setState({ minimized: true });
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
try {
await act(async () => root.render(<ChatPage />));
expect(useChatPanelStore.getState().minimized).toBe(false);
expect(host.querySelector('[data-chat][data-inline="true"]')).not.toBeNull();
} finally {
await act(async () => root.unmount()); host.remove();
}
});
20 changes: 20 additions & 0 deletions src/app/(main)/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"use client";

import { useEffect } from "react";

import { ChatPanel } from "@/components/chat";
import { useMobileView } from "@/contexts/MobileViewContext";
import { useChatPanelStore } from "@/stores/chat-panel-store";

/** `/chat` — 최대화 채팅 화면. PC는 지도 옆 본문 자리에, 모바일은 전체 화면으로 그린다 */
export default function ChatPage() {
const { isMobileDevice } = useMobileView();

// 어떤 경로로 들어오든(사이드바·뒤로가기·주소 입력) 채팅에 들어오면 최소화를 푼다.
// 그래야 메뉴로 나갈 때 최소화 창이 다시 떠오르지 않는다.
useEffect(() => {
useChatPanelStore.getState().setMinimized(false);
}, []);

return isMobileDevice ? <ChatPanel mobileInline /> : <ChatPanel inline />;
}
173 changes: 173 additions & 0 deletions src/app/(main)/map/_components/MobileRouteCandidateCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"use client";

import { useState } from "react";

import { CloseIcon, PlusIcon, ReactionStarIcon } from "@/assets/icons";
import { DashedBorder } from "@/components/mobile/DashedPlaceholderBox";
import { usePlaceDetailData } from "@/components/place/usePlaceDetailData";
import { cn } from "@/lib/utils";

import { MobileRouteExpandedShell } from "./MobileRoutePlaceCard";
import { MobileRoutePlaceDetail } from "./MobileRoutePlaceDetail";

/**
* 경로 보기에서 지도의 Google 장소를 누르면 카드 줄의 넣을 자리(고른 카드 바로 뒤)에 끼우는 후보 카드.
* 일정 장소 카드와 같은 배치(배지·이름·분류)를 쓰되, 아직 넣지 않은 장소라 점선 테두리와 "+" 배지로 구분한다.
* 카드를 누르면 다른 경로 카드처럼 사진·홈·리뷰 상세로 펼친다.
*/
export function MobileRouteCandidateCard({
googlePlaceId,
badgeColor,
onAdd,
onClose,
expanded,
onExpand,
onCollapse,
detailActive,
}: Readonly<{
googlePlaceId: string;
/** 넣을 일차 색 */
badgeColor: string;
/** 추가에 성공하면 true — 실패하면 카드를 남겨 다시 누를 수 있게 한다 */
onAdd: () => Promise<boolean>;
onClose: () => void;
expanded: boolean;
onExpand: () => void;
onCollapse: () => void;
/** 가운데 카드일 때만 펼친 상세를 불러온다 */
detailActive: boolean;
}>) {
const { data: detail, isLoading } = usePlaceDetailData(googlePlaceId);
const [adding, setAdding] = useState(false);
const name = detail?.name?.trim() || "장소";
const category = detail?.primaryTypeDisplayName?.trim() ?? "";
const address = detail?.formattedAddress?.trim() ?? "";

async function handleAdd() {
if (adding) return;
setAdding(true);
const added = await onAdd();
// 성공하면 카드가 사라지므로 실패했을 때만 되돌린다
if (!added) setAdding(false);
}

// 접힌 카드는 넘겨 보는 줄 높이(가장 긴 카드)를 채우고, 정보는 위·추가 버튼은 아래에 붙인다
const card = (
<div className={cn("relative", !expanded && "h-full")}>
{expanded ? null : (
<button
type="button"
onClick={onExpand}
aria-label={`후보 장소 ${name} 상세 보기`}
className="absolute inset-0 cursor-pointer rounded-md focus-visible:outline-2 focus-visible:outline-primary-strong"
/>
)}
<div className="pointer-events-none relative flex h-full gap-3 pb-4 pl-3.5 pr-2 pt-3">
{/* 순번 자리 — 아직 순번이 없어 일차 색 점선 배지에 "+" */}
<span
aria-hidden
className="mt-px flex size-6 shrink-0 items-center justify-center rounded-[5.5px] border border-dashed bg-fill-subtle"
style={{ borderColor: badgeColor, color: badgeColor }}
>
<PlusIcon size={16} />
</span>
<div className="flex min-w-0 flex-1 flex-col">
{isLoading && !detail ? (
<output
aria-label="장소 정보 불러오는 중"
aria-busy
className="flex flex-col gap-1.5 pt-0.5"
>
<span className="h-5 w-2/3 animate-pulse rounded bg-fill-strong" />
<span className="h-4 w-1/2 animate-pulse rounded bg-fill" />
</output>
) : (
<>
<h3 className="line-clamp-2 break-words text-title-s text-text">
{name}
</h3>
<CandidateMetadata category={category} address={address} rating={detail?.rating} />
</>
)}
<div className="mt-auto pt-3.5">
<button
type="button"
onClick={() => void handleAdd()}
disabled={adding}
className="pointer-events-auto flex h-9 w-full cursor-pointer items-center justify-center gap-1 rounded-lg bg-primary text-label-m-emphasis text-text-inverse disabled:cursor-not-allowed disabled:bg-fill disabled:text-text-disabled"
>
<PlusIcon size={16} className="shrink-0" />이 자리에 추가
</button>
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="후보 장소 닫기"
className="pointer-events-auto -mt-1 flex size-8 shrink-0 cursor-pointer items-center justify-center"
>
<CloseIcon size={20} className="text-icon-subtle" />
</button>
</div>
</div>
);

if (!expanded) {
return (
// 다른 장소 카드와 같은 배경에 점선 테두리만 달리해 아직 넣지 않은 자리임을 보인다
<div className="relative h-full rounded-md bg-fill-subtle">
<DashedBorder active className="inset-[0.5px] size-[calc(100%-1px)]" />
{card}
</div>
);
}

return (
<MobileRouteExpandedShell onCollapse={onCollapse} tentative>
{card}
<MobileRoutePlaceDetail
place={{
googlePlaceId,
title: name,
subtitle: address || undefined,
location: detail?.location ?? undefined,
primaryTypeDisplayName: category || undefined,
}}
active={detailActive}
analyticsSource="map"
/>
</MobileRouteExpandedShell>
);
}

function CandidateMetadata({ category, address, rating }: Readonly<{
category: string;
address: string;
rating: number | null | undefined;
}>) {
return (
<p className="flex min-w-0 items-center gap-1 text-body-s-regular text-text-subtle">
{category ? <span className="shrink-0">{category}</span> : null}
{rating != null ? (
<span className="flex shrink-0 items-center gap-0.5">
{category ? <span aria-hidden>·</span> : null}
<ReactionStarIcon
size={14}
className="fill-current text-[var(--orange-500)] [&_path]:stroke-none"
/>
<span className="tabular-nums">
{rating.toFixed(1)}
</span>
</span>
) : null}
{address ? (
<>
{category || rating != null ? (
<span aria-hidden>·</span>
) : null}
<span className="truncate">{address}</span>
</>
) : null}
</p>
);
}
125 changes: 125 additions & 0 deletions src/app/(main)/map/_components/MobileRouteCardCarousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"use client";

import { useEffect, useRef, type ReactNode } from "react";

/** 손을 뗀 뒤 스냅이 멈출 때까지 기다렸다가 고른 카드를 알린다 — 넘기는 중 지도가 여러 번 움직이지 않게 */
const SCROLL_SETTLE_MS = 120;

type CarouselCard = { key: string; node: ReactNode };

/**
* 카드를 가로로 넘겨 본다 — 가운데 카드가 고른 카드이고 양옆 카드가 조금 보인다.
* 카드를 넘기면 `onFocusChange`로 알리고, 바깥(지도 핀·일차 탭·처음 진입·삭제)에서 고른 카드가 바뀌면 그 카드로 옮긴다.
* 넘기는 중에는 `onPreviewChange`로 지금 가운데 카드를 바로 알린다 — 일차 탭처럼 가벼운 표시만 먼저 바꾸는 데 쓴다.
*/
export function MobileRouteCardCarousel({
cards,
focusedKey,
onFocusChange,
onPreviewChange,
}: Readonly<{
cards: readonly CarouselCard[];
focusedKey: string | null;
onFocusChange: (index: number) => void;
/** 넘기는 중 가운데 카드(멈추면 null) */
onPreviewChange?: (index: number | null) => void;
}>) {
const containerRef = useRef<HTMLDivElement>(null);
const settleTimerRef = useRef<number | null>(null);
/** 카드를 넘겨서 고른 카드 — 이 경우엔 다시 스크롤하지 않는다(한 번 쓰고 비운다) */
const fromScrollRef = useRef<string | null>(null);
/** 코드로 옮기는 중인 카드 — 도착할 때까지 스크롤로 고르지 않는다. 사용자가 카드 줄을 만지면 버린다 */
const scrollTargetRef = useRef<number | null>(null);
const scrolledOnceRef = useRef(false);
const previewRef = useRef<number | null>(null);
const setPreview = (index: number | null) => {
if (previewRef.current === index) return;
previewRef.current = index;
onPreviewChange?.(index);
};

const focusedIndex = cards.findIndex((card) => card.key === focusedKey);

useEffect(() => {
const el = containerRef.current;
if (!el || focusedIndex < 0) return;
// 넘겨서 고른 직후엔 이미 그 자리에 있다. 기록은 비워 두어, 나중에 코드로 같은 카드를 다시 고르거나
// 카드가 끼워지고 빠져 자리가 밀려도 그 카드로 옮기게 한다
const fromScroll = fromScrollRef.current === focusedKey;
fromScrollRef.current = null;
if (fromScroll) return;
const child = el.children[focusedIndex] as HTMLElement | undefined;
if (!child) return;
const left = child.offsetLeft - (el.clientWidth - child.clientWidth) / 2;
if (Math.abs(el.scrollLeft - left) < 2) return;
scrollTargetRef.current = focusedIndex;
el.scrollTo({ left, behavior: scrolledOnceRef.current ? "smooth" : "auto" });
scrolledOnceRef.current = true;
}, [focusedIndex, focusedKey]);

useEffect(
() => () => {
if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);
},
[],
);

function nearestIndex(el: HTMLDivElement) {
const center = el.scrollLeft + el.clientWidth / 2;
let best = 0;
let bestDistance = Number.POSITIVE_INFINITY;
Array.from(el.children).forEach((node, index) => {
const child = node as HTMLElement;
const distance = Math.abs(child.offsetLeft + child.clientWidth / 2 - center);
if (distance < bestDistance) {
bestDistance = distance;
best = index;
}
});
return best;
}

function handleScroll() {
// 코드로 옮기는 중엔 목적지가 이미 골라져 있으니 지나가는 카드를 알리지 않는다
const current = containerRef.current;
if (current && scrollTargetRef.current === null) setPreview(nearestIndex(current));
if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);
settleTimerRef.current = window.setTimeout(() => {
settleTimerRef.current = null;
const el = containerRef.current;
if (!el) return;
const index = nearestIndex(el);
// 같은 타이머 안이라 아래 onFocusChange와 함께 한 번에 그려진다
setPreview(null);
// 코드로 옮기는 중이면 이미 고른 카드다 — 도착했을 때만 표시를 지운다(도중에 멈칫한 건 무시)
if (scrollTargetRef.current !== null) {
if (scrollTargetRef.current === index) scrollTargetRef.current = null;
return;
}
const key = cards[index]?.key;
if (key === undefined || key === focusedKey) return;
fromScrollRef.current = key;
onFocusChange(index);
}, SCROLL_SETTLE_MS);
}

return (
<div
ref={containerRef}
onScroll={handleScroll}
// 코드로 옮기는 중에 사용자가 넘기면 손을 따른다 — 목적지를 버려야 멈춘 카드를 고르고 일차 탭도 따라온다
onPointerDown={() => {
scrollTargetRef.current = null;
}}
// relative: 카드 위치(offsetLeft)를 카드 줄 기준으로 재서 브라우저마다 같은 값이 나오게 한다
className="pointer-events-auto relative flex h-full snap-x snap-mandatory gap-2 overflow-x-auto overscroll-x-contain px-[18px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{cards.map((card) => (
// 카드 폭은 좌우 여백(18px)을 뺀 전체 — 393px 화면에서 Figma처럼 357px, 옆 카드가 10px 보인다
<div key={card.key} className="h-full w-full shrink-0 snap-center snap-always">
{card.node}
</div>
))}
</div>
);
}
Loading