From 3fc9c410aebc31fa82bdbde04ac8fb869b70cee7 Mon Sep 17 00:00:00 2001 From: yuji1202 Date: Thu, 30 Apr 2026 12:31:52 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마이페이지 메인 화면 UI 구현 - 저장 장소 요약 카드 및 저장 코스 리스트 UI 구현 - 나의 장소 전체보기 화면 UI 구현 - 저장 장소 메모 작성/수정/삭제 UI 구현 - 저장 장소 없음 / 필터 결과 없음 상태 UI 구현 - 저장 코스 상세 화면 UI 구현 - /dev/mypage 임시 확인 경로 추가 --- src/app/router/index.tsx | 5 + src/components/mypage/MyAccountActions.tsx | 25 +++ src/components/mypage/MyPageCourseDetail.tsx | 59 ++++++ src/components/mypage/MyPlaceSummaryCard.tsx | 55 +++++ src/components/mypage/MyProfileHeader.tsx | 16 ++ src/components/mypage/MySavedPlacesPage.tsx | 116 ++++++++++ src/components/mypage/SavedCourseCard.tsx | 39 ++++ src/components/mypage/SavedCourseListPage.tsx | 200 ++++++++++++++++++ src/components/mypage/SavedCourseSection.tsx | 52 +++++ .../mypage/SavedPlaceCategoryTabs.tsx | 45 ++++ .../mypage/SavedPlaceDetailPage.tsx | 107 ++++++++++ src/components/mypage/SavedPlaceItem.tsx | 79 +++++++ .../mypage/SavedPlaceMemoEditor.tsx | 39 ++++ src/components/mypage/mypage-mock-data.ts | 173 +++++++++++++++ src/pages/tabs/MyPage.tsx | 93 +++++++- 15 files changed, 1101 insertions(+), 2 deletions(-) create mode 100644 src/components/mypage/MyAccountActions.tsx create mode 100644 src/components/mypage/MyPageCourseDetail.tsx create mode 100644 src/components/mypage/MyPlaceSummaryCard.tsx create mode 100644 src/components/mypage/MyProfileHeader.tsx create mode 100644 src/components/mypage/MySavedPlacesPage.tsx create mode 100644 src/components/mypage/SavedCourseCard.tsx create mode 100644 src/components/mypage/SavedCourseListPage.tsx create mode 100644 src/components/mypage/SavedCourseSection.tsx create mode 100644 src/components/mypage/SavedPlaceCategoryTabs.tsx create mode 100644 src/components/mypage/SavedPlaceDetailPage.tsx create mode 100644 src/components/mypage/SavedPlaceItem.tsx create mode 100644 src/components/mypage/SavedPlaceMemoEditor.tsx create mode 100644 src/components/mypage/mypage-mock-data.ts diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index c9c6aa6..0fbccbe 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -13,6 +13,7 @@ import { mapHomeLoader } from "@/pages/map/map-home-loader"; import NicknamePage from "@/pages/onboarding/NicknamePage"; import TermsAgreementPage from "@/pages/onboarding/TermsAgreementPage"; import SplashScreenPage from "@/pages/SplashScreenPage"; +import MyPagePreview from "@/pages/tabs/MyPage"; const MapHomePage = lazy(() => import("@/pages/MapHomePage")); const RoomMainPage = lazy(() => import("@/pages/room/RoomMainPage")); @@ -29,6 +30,7 @@ export const router = createBrowserRouter([ { path: "dev/splash", element: }, { path: "dev/click_place", element: }, { path: "dev/SelectOption", element: }, + { path: "dev/mypage", element: }, { path: "login", element: }, { path: "app", element: }, { @@ -70,3 +72,6 @@ export const router = createBrowserRouter([ ], }, ]); + + + diff --git a/src/components/mypage/MyAccountActions.tsx b/src/components/mypage/MyAccountActions.tsx new file mode 100644 index 0000000..2bcc02c --- /dev/null +++ b/src/components/mypage/MyAccountActions.tsx @@ -0,0 +1,25 @@ +type MyAccountActionsProps = { + onLogout?: () => void; + onWithdraw?: () => void; +}; + +export function MyAccountActions({ onLogout, onWithdraw }: MyAccountActionsProps) { + return ( +
+ + +
+ ); +} diff --git a/src/components/mypage/MyPageCourseDetail.tsx b/src/components/mypage/MyPageCourseDetail.tsx new file mode 100644 index 0000000..b6d759a --- /dev/null +++ b/src/components/mypage/MyPageCourseDetail.tsx @@ -0,0 +1,59 @@ +import { ArrowLeft, Footprints, MapPin, Pencil } from "lucide-react"; + +import type { SavedCourse } from "./mypage-mock-data"; + +type MyPageCourseDetailProps = { + course: SavedCourse; + onBack: () => void; +}; + +export function MyPageCourseDetail({ course, onBack }: MyPageCourseDetailProps) { + return ( +
+
+
+
+
+
+ {["left-20 top-24", "left-36 top-16", "left-48 top-32"].map((position, index) => ( + + {index + 1} + + ))} +
+ +
+
+ +
+ +

{course.title}

+ +
+ +
    + {course.stops.map((stop) => ( +
  1. + +

    {stop.name}

    +

    {stop.address}

    + + {stop.walkingTime ? ( +

    + + {stop.walkingTime} +

    + ) : null} +
  2. + ))} +
+
+
+ ); +} diff --git a/src/components/mypage/MyPlaceSummaryCard.tsx b/src/components/mypage/MyPlaceSummaryCard.tsx new file mode 100644 index 0000000..a212ef1 --- /dev/null +++ b/src/components/mypage/MyPlaceSummaryCard.tsx @@ -0,0 +1,55 @@ +import { ChevronRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +import type { RecentPlace } from "./mypage-mock-data"; + +type MyPlaceSummaryCardProps = { + totalCount: number; + recentPlaces: RecentPlace[]; + onOpenPlaces?: () => void; + className?: string; +}; + +function formatCount(count: number) { + return count > 999 ? "999+" : String(count); +} + +export function MyPlaceSummaryCard({ + totalCount, + recentPlaces, + onOpenPlaces, + className, +}: MyPlaceSummaryCardProps) { + const hasPlaces = totalCount > 0; + + return ( +
+ + +

{formatCount(totalCount)}개

+ +
+

최근 저장 장소

+
+ {hasPlaces ? ( + recentPlaces.slice(0, 2).map((place) => ( +

+ {place.name} +

+ )) + ) : ( +

나의 장소를 저장해보세요!

+ )} +
+
+
+ ); +} diff --git a/src/components/mypage/MyProfileHeader.tsx b/src/components/mypage/MyProfileHeader.tsx new file mode 100644 index 0000000..46fbca4 --- /dev/null +++ b/src/components/mypage/MyProfileHeader.tsx @@ -0,0 +1,16 @@ +import { UserRound } from "lucide-react"; + +type MyProfileHeaderProps = { + nickname: string; +}; + +export function MyProfileHeader({ nickname }: MyProfileHeaderProps) { + return ( +
+ + + +

{nickname}님

+
+ ); +} diff --git a/src/components/mypage/MySavedPlacesPage.tsx b/src/components/mypage/MySavedPlacesPage.tsx new file mode 100644 index 0000000..943c022 --- /dev/null +++ b/src/components/mypage/MySavedPlacesPage.tsx @@ -0,0 +1,116 @@ +import { AlertCircle, ArrowLeft } from "lucide-react"; +import { useMemo, useState } from "react"; + +import type { SavedPlace } from "./mypage-mock-data"; +import { SavedPlaceCategoryTabs, type SavedPlaceFilter } from "./SavedPlaceCategoryTabs"; +import { SavedPlaceItem } from "./SavedPlaceItem"; + +type MySavedPlacesPageProps = { + places: SavedPlace[]; + onBack: () => void; + onChangePlaces: (places: SavedPlace[]) => void; + onSelectPlace: (place: SavedPlace) => void; +}; + +function formatCount(count: number) { + return count > 999 ? "999+" : String(count); +} + +export function MySavedPlacesPage({ places, onBack, onChangePlaces, onSelectPlace }: MySavedPlacesPageProps) { + const [selectedFilter, setSelectedFilter] = useState("all"); + const [openMenuId, setOpenMenuId] = useState(null); + const [editingPlaceId, setEditingPlaceId] = useState(null); + const [memoDraft, setMemoDraft] = useState(""); + + const filteredPlaces = useMemo(() => { + if (selectedFilter === "all") { + return places; + } + + return places.filter((place) => place.category === selectedFilter); + }, [places, selectedFilter]); + + const emptyMessage = places.length === 0 ? "나의 장소를 저장해보세요!" : "해당하는 장소가 없습니다."; + + const handleStartMemo = (place: SavedPlace) => { + setOpenMenuId(null); + setEditingPlaceId(place.id); + setMemoDraft(place.memo ?? ""); + }; + + const handleSaveMemo = () => { + if (!editingPlaceId) { + return; + } + + const nextMemo = memoDraft.trim(); + onChangePlaces( + places.map((place) => + place.id === editingPlaceId + ? { + ...place, + memo: nextMemo || undefined, + } + : place, + ), + ); + setEditingPlaceId(null); + setMemoDraft(""); + }; + + const handleDeletePlace = (id: string) => { + onChangePlaces(places.filter((place) => place.id !== id)); + setOpenMenuId(null); + if (editingPlaceId === id) { + setEditingPlaceId(null); + setMemoDraft(""); + } + }; + + return ( +
+
+
+ +

나의 장소

+ 총 {formatCount(places.length)}개 +
+ + +
+ +
+ {filteredPlaces.length > 0 ? ( +
+ {filteredPlaces.map((place) => ( + setOpenMenuId((current) => (current === id ? null : id))} + onStartMemo={handleStartMemo} + onChangeMemo={setMemoDraft} + onSaveMemo={handleSaveMemo} + onClearMemo={() => setMemoDraft("")} + onDelete={handleDeletePlace} + onSelect={onSelectPlace} + /> + ))} +
+ ) : ( +
+ + + +

{emptyMessage}

+
+ )} +
+
+ ); +} diff --git a/src/components/mypage/SavedCourseCard.tsx b/src/components/mypage/SavedCourseCard.tsx new file mode 100644 index 0000000..8056065 --- /dev/null +++ b/src/components/mypage/SavedCourseCard.tsx @@ -0,0 +1,39 @@ +import { ChevronRight, Heart, UsersRound } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +import type { SavedCourse } from "./mypage-mock-data"; + +type SavedCourseCardProps = { + course: SavedCourse; + onSelect?: (course: SavedCourse) => void; + className?: string; +}; + +export function SavedCourseCard({ course, onSelect, className }: SavedCourseCardProps) { + const isFriendCourse = course.badgeLabel === "친구"; + const Icon = isFriendCourse ? UsersRound : Heart; + + return ( + + ); +} diff --git a/src/components/mypage/SavedCourseListPage.tsx b/src/components/mypage/SavedCourseListPage.tsx new file mode 100644 index 0000000..9f21974 --- /dev/null +++ b/src/components/mypage/SavedCourseListPage.tsx @@ -0,0 +1,200 @@ +import { AlertCircle, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "@/lib/utils"; + +import type { SavedCourse } from "./mypage-mock-data"; +import { SavedCourseCard } from "./SavedCourseCard"; + +type SavedCourseListPageProps = { + courses: SavedCourse[]; + onBack: () => void; + onSelectCourse: (course: SavedCourse) => void; +}; + +type CourseFilter = "all" | "room" | "date"; +type FilterPopup = Exclude | null; + +const rooms = ["친구와의 방", "연인의 방", "남친구와의 방", "친구 4, 친구 5, 친구 6..."]; +const aprilDates = Array.from({ length: 30 }, (_, index) => index + 1); + +function formatCount(count: number) { + return count > 999 ? "999+" : String(count); +} + +function formatDateLabel(date: number | null) { + return date ? `2025.04.${String(date).padStart(2, "0")}` : "날짜"; +} + +export function SavedCourseListPage({ courses, onBack, onSelectCourse }: SavedCourseListPageProps) { + const [selectedFilter, setSelectedFilter] = useState("all"); + const [openPopup, setOpenPopup] = useState(null); + const [selectedRooms, setSelectedRooms] = useState([rooms[1]]); + const [selectedDate, setSelectedDate] = useState(null); + + const visibleCourses = useMemo(() => { + if (selectedFilter === "date" && selectedDate === 26) { + return []; + } + + if (selectedFilter === "date" && selectedDate) { + return courses.slice(0, 4); + } + + if (selectedFilter === "room" && selectedRooms.length === 0) { + return []; + } + + if (selectedFilter === "room") { + return courses.slice(0, 4); + } + + return courses; + }, [courses, selectedDate, selectedFilter, selectedRooms.length]); + + const handleSelectAll = () => { + setSelectedFilter("all"); + setOpenPopup(null); + }; + + const handleToggleRoom = (room: string) => { + setSelectedFilter("room"); + setSelectedRooms((current) => (current.includes(room) ? current.filter((item) => item !== room) : [...current, room])); + }; + + const handleSelectDate = (date: number) => { + setSelectedFilter("date"); + setSelectedDate(date); + setOpenPopup(null); + }; + + return ( +
+
+
+ +

저장된 데이트 코스

+ 총 {formatCount(visibleCourses.length)}개 +
+ +
+ + +
+ + + {openPopup === "room" ? ( +
+ {rooms.map((room) => { + const checked = selectedRooms.includes(room); + return ( + + ); + })} +
+ ) : null} +
+ +
+ + + {openPopup === "date" ? ( +
+
+ April 2025 +
+ + +
+
+
+ {['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((day) => ( + {day} + ))} +
+
+ {aprilDates.map((date) => ( + + ))} +
+
+ ) : null} +
+
+
+ +
+ {visibleCourses.length > 0 ? ( +
+ {visibleCourses.map((course) => ( + + ))} +
+ ) : ( +
+ + + +

해당하는 데이트 코스가 없습니다.

+
+ )} +
+
+ ); +} diff --git a/src/components/mypage/SavedCourseSection.tsx b/src/components/mypage/SavedCourseSection.tsx new file mode 100644 index 0000000..65694f4 --- /dev/null +++ b/src/components/mypage/SavedCourseSection.tsx @@ -0,0 +1,52 @@ +import type { SavedCourse } from "./mypage-mock-data"; +import { SavedCourseCard } from "./SavedCourseCard"; + +type SavedCourseSectionProps = { + courses: SavedCourse[]; + visibleCount: number; + onShowMore: () => void; + onShowAll: () => void; + onSelectCourse: (course: SavedCourse) => void; +}; + +export function SavedCourseSection({ + courses, + visibleCount, + onShowMore, + onShowAll, + onSelectCourse, +}: SavedCourseSectionProps) { + const visibleCourses = courses.slice(0, visibleCount); + const hasCourses = courses.length > 0; + const hasHiddenCourses = visibleCount < courses.length; + const actionLabel = hasHiddenCourses ? "5개 더보기" : "코스 전체보기"; + + return ( +
+

저장된 데이트 코스

+ + {hasCourses ? ( + <> +
+ {visibleCourses.map((course) => ( + + ))} +
+ + + + ) : ( +
+ 데이트 코스를 저장해보세요! +
+ )} +
+ ); +} diff --git a/src/components/mypage/SavedPlaceCategoryTabs.tsx b/src/components/mypage/SavedPlaceCategoryTabs.tsx new file mode 100644 index 0000000..c4a3d85 --- /dev/null +++ b/src/components/mypage/SavedPlaceCategoryTabs.tsx @@ -0,0 +1,45 @@ +import { cn } from "@/lib/utils"; + +import type { SavedPlaceCategory } from "./mypage-mock-data"; + +export type SavedPlaceFilter = "all" | SavedPlaceCategory; + +const filters: { id: SavedPlaceFilter; label: string }[] = [ + { id: "all", label: "전체" }, + { id: "food", label: "맛집" }, + { id: "cafe", label: "카페" }, + { id: "activity", label: "놀거리" }, + { id: "etc", label: "기타" }, +]; + +type SavedPlaceCategoryTabsProps = { + selected: SavedPlaceFilter; + onSelect: (filter: SavedPlaceFilter) => void; +}; + +export function SavedPlaceCategoryTabs({ selected, onSelect }: SavedPlaceCategoryTabsProps) { + return ( +
+ {filters.map((filter) => { + const active = selected === filter.id; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/mypage/SavedPlaceDetailPage.tsx b/src/components/mypage/SavedPlaceDetailPage.tsx new file mode 100644 index 0000000..50c296a --- /dev/null +++ b/src/components/mypage/SavedPlaceDetailPage.tsx @@ -0,0 +1,107 @@ +import { ArrowLeft, ChevronDown, ExternalLink, MapPin } from "lucide-react"; + +import type { SavedPlace } from "./mypage-mock-data"; + +const TEXT = { + mapTitle: "\uB098\uB9CC\uC758 \uC9C0\uB3C4", + backToPlaces: "\uB098\uC758 \uC7A5\uC18C\uB85C \uB3CC\uC544\uAC00\uAE30", + reelsButton: "\uB0B4\uAC00 \uBD24\uB358 \uB9B4\uC2A4 \uB2E4\uC2DC\uBCF4\uAE30", + openedAt: "\uC601\uC5C5 \uC804 10:40 \uC624\uD508", + sharedBy: "\uACF5\uC720\uB41C \uC7A5\uC18C \uC601\uC5C5", + todayHours: "\uD1A0(4/11) 10:40 ~ 19:30", + category: "\uC885\uB958", +} as const; + +function getCategoryLabel(category: SavedPlace["category"]) { + const labels: Record = { + food: "\uB9DB\uC9D1", + cafe: "\uCE74\uD398", + activity: "\uB180\uAC70\uB9AC", + etc: "\uAE30\uD0C0", + }; + + return labels[category]; +} + +type SavedPlaceDetailPageProps = { + place: SavedPlace; + onBack: () => void; +}; + +export function SavedPlaceDetailPage({ place, onBack }: SavedPlaceDetailPageProps) { + return ( +
+
+
+
+
+
+
+
+ + {[ + "left-[22%] top-[48%]", + "left-[50%] top-[35%]", + "right-[18%] top-[54%]", + ].map((position) => ( + + + + ))} + +
+
+ + {TEXT.mapTitle} +
+
+
+ +
+
+ +
+ +

{place.name}

+
+ +

{place.address}

+ + + +
+
+
{TEXT.openedAt}
+
{TEXT.sharedBy}
+
+
+
\uC624\uB298 \uC601\uC5C5\uC2DC\uAC04
+
+ {TEXT.todayHours} + +
+
+
+
{TEXT.category}
+
+ {getCategoryLabel(place.category)} +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/mypage/SavedPlaceItem.tsx b/src/components/mypage/SavedPlaceItem.tsx new file mode 100644 index 0000000..8512e75 --- /dev/null +++ b/src/components/mypage/SavedPlaceItem.tsx @@ -0,0 +1,79 @@ +import { MoreVertical } from "lucide-react"; + +import type { SavedPlace } from "./mypage-mock-data"; +import { SavedPlaceMemoEditor } from "./SavedPlaceMemoEditor"; + +type SavedPlaceItemProps = { + place: SavedPlace; + isMenuOpen: boolean; + isEditing: boolean; + memoDraft: string; + onToggleMenu: (id: string) => void; + onStartMemo: (place: SavedPlace) => void; + onChangeMemo: (value: string) => void; + onSaveMemo: () => void; + onClearMemo: () => void; + onDelete: (id: string) => void; + onSelect: (place: SavedPlace) => void; +}; + +export function SavedPlaceItem({ + place, + isMenuOpen, + isEditing, + memoDraft, + onToggleMenu, + onStartMemo, + onChangeMemo, + onSaveMemo, + onClearMemo, + onDelete, + onSelect, +}: SavedPlaceItemProps) { + return ( +
+
+ + + +
+ + {place.memo && !isEditing ? ( +

{place.memo}

+ ) : null} + + {isEditing ? ( + + ) : null} + + {isMenuOpen ? ( +
+ + +
+ ) : null} +
+ ); +} diff --git a/src/components/mypage/SavedPlaceMemoEditor.tsx b/src/components/mypage/SavedPlaceMemoEditor.tsx new file mode 100644 index 0000000..dfcc01e --- /dev/null +++ b/src/components/mypage/SavedPlaceMemoEditor.tsx @@ -0,0 +1,39 @@ +import { X } from "lucide-react"; + +const MAX_MEMO_LENGTH = 50; + +type SavedPlaceMemoEditorProps = { + value: string; + onChange: (value: string) => void; + onSave: () => void; + onClear: () => void; +}; + +export function SavedPlaceMemoEditor({ value, onChange, onSave, onClear }: SavedPlaceMemoEditorProps) { + return ( +
+ onChange(event.target.value)} + onBlur={onSave} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + className="min-w-0 flex-1 bg-transparent text-xs font-medium text-[#222222] outline-none placeholder:text-[#9a9a9a]" + placeholder="메모를 남겨주세요." + autoFocus + /> + {value ? ( + + ) : null} + {value.length}/50 +
+ ); +} diff --git a/src/components/mypage/mypage-mock-data.ts b/src/components/mypage/mypage-mock-data.ts new file mode 100644 index 0000000..80fc0fe --- /dev/null +++ b/src/components/mypage/mypage-mock-data.ts @@ -0,0 +1,173 @@ +export type SavedPlaceCategory = "food" | "cafe" | "activity" | "etc"; + +export type RecentPlace = { + id: string; + name: string; +}; + +export type SavedPlace = { + id: string; + name: string; + address: string; + category: SavedPlaceCategory; + memo?: string; +}; + +export type SavedCourse = { + id: string; + title: string; + executedAtLabel: string; + badgeLabel: string; + stops: CourseStop[]; +}; + +export type CourseStop = { + id: string; + name: string; + address: string; + walkingTime?: string; + hours?: string; +}; + +export const myPageUser = { + nickname: "홍길동", + savedPlaceCount: 58, + recentPlaces: [ + { id: "place-recent-1", name: "아임파이" }, + { id: "place-recent-2", name: "투썸플레이스" }, + ], +}; + +export const savedPlaces: SavedPlace[] = [ + { + id: "place-1", + name: "아임파이", + address: "서울 동대문구 회기로 116-1 2층 (회기동)", + category: "cafe", + }, + { + id: "place-2", + name: "감동", + address: "서울 동대문구 회기로 25길 101-13 1층", + category: "food", + memo: "커피 완전 맛있음 ★★★", + }, + { + id: "place-3", + name: "한국외국어대학교 서울캠퍼스", + address: "서울 동대문구 회기로 116-1 2층 (회기동)", + category: "etc", + }, + { + id: "place-4", + name: "언니네함박그", + address: "서울 동대문구 회기로25길 59 1층 언니네함박그", + category: "food", + }, + { + id: "place-5", + name: "무감커피바", + address: "서울 동대문구 회기로21길 19 지하1층", + category: "cafe", + }, + { + id: "place-6", + name: "호현장담 외대후문점", + address: "서울 동대문구 천장산로7길 10-1 2층 호현장담", + category: "activity", + }, +]; + +export const savedCourses: SavedCourse[] = [ + { + id: "course-1", + title: "서울 동대문구 0408", + executedAtLabel: "2일 전 실행한 코스", + badgeLabel: "친구", + stops: [ + { + id: "stop-1", + name: "한국외국어대학교 서울캠퍼스", + address: "서울 동대문구 이문로 107", + walkingTime: "도보 10분", + }, + { + id: "stop-2", + name: "감동", + address: "회기로 25길 101-13 1층", + walkingTime: "도보 10분", + }, + { + id: "stop-3", + name: "샤로스톤 외대점", + address: "회기로 27", + }, + ], + }, + { + id: "course-2", + title: "망원동 벚꽃데이트 코스", + executedAtLabel: "7일 전 실행한 코스", + badgeLabel: "하트", + stops: [ + { + id: "stop-4", + name: "망원시장", + address: "서울 마포구 포은로8길 14", + walkingTime: "도보 8분", + }, + { + id: "stop-5", + name: "망원한강공원", + address: "서울 마포구 마포나루길 467", + }, + ], + }, + { + id: "course-3", + title: "간만에 휴가 데이트", + executedAtLabel: "03.08 실행한 코스", + badgeLabel: "하트", + stops: [ + { + id: "stop-6", + name: "연남동 산책길", + address: "서울 마포구 연남동", + walkingTime: "도보 12분", + }, + { + id: "stop-7", + name: "작은 카페", + address: "서울 마포구 동교로 41길", + }, + ], + }, + { + id: "course-4", + title: "간만에 휴가 데이트", + executedAtLabel: "03.08 실행한 코스", + badgeLabel: "하트", + stops: [{ id: "stop-8", name: "성수 카페거리", address: "서울 성동구 성수동" }], + }, + { + id: "course-5", + title: "간만에 휴가 데이트", + executedAtLabel: "03.08 실행한 코스", + badgeLabel: "하트", + stops: [{ id: "stop-9", name: "서울숲", address: "서울 성동구 뚝섬로 273" }], + }, + { + id: "course-6", + title: "간만에 휴가 데이트", + executedAtLabel: "03.08 실행한 코스", + badgeLabel: "하트", + stops: [{ id: "stop-10", name: "뚝섬 한강공원", address: "서울 광진구 강변북로 139" }], + }, + { + id: "course-7", + title: "간만에 휴가 데이트", + executedAtLabel: "03.08 실행한 코스", + badgeLabel: "하트", + stops: [{ id: "stop-11", name: "압구정 로데오", address: "서울 강남구 압구정로" }], + }, +]; diff --git a/src/pages/tabs/MyPage.tsx b/src/pages/tabs/MyPage.tsx index fad6b82..4a0ad36 100644 --- a/src/pages/tabs/MyPage.tsx +++ b/src/pages/tabs/MyPage.tsx @@ -1,13 +1,102 @@ +import { useState } from "react"; + import { BottomNavigationBar } from "@/components/common/BottomNavigationBar"; import { BottomNavToast } from "@/components/common/BottomNavToast"; +import { MyAccountActions } from "@/components/mypage/MyAccountActions"; +import { + myPageUser, + savedCourses, + savedPlaces as initialSavedPlaces, + type SavedCourse, + type SavedPlace, +} from "@/components/mypage/mypage-mock-data"; +import { MyPageCourseDetail } from "@/components/mypage/MyPageCourseDetail"; +import { SavedCourseListPage } from "@/components/mypage/SavedCourseListPage"; +import { SavedPlaceDetailPage } from "@/components/mypage/SavedPlaceDetailPage"; +import { MyPlaceSummaryCard } from "@/components/mypage/MyPlaceSummaryCard"; +import { MyProfileHeader } from "@/components/mypage/MyProfileHeader"; +import { MySavedPlacesPage } from "@/components/mypage/MySavedPlacesPage"; +import { SavedCourseSection } from "@/components/mypage/SavedCourseSection"; import { useBottomNavController } from "@/hooks/use-bottom-nav-controller"; +type MyPageView = "main" | "places" | "courses"; + export default function MyPage() { const { toastMessage, handleSelectBottomNav } = useBottomNavController(); + const [view, setView] = useState("main"); + const [visibleCourseCount, setVisibleCourseCount] = useState(5); + const [selectedCourse, setSelectedCourse] = useState(null); + const [selectedPlace, setSelectedPlace] = useState(null); + const [places, setPlaces] = useState(initialSavedPlaces); + + const handleShowMoreCourses = () => { + setVisibleCourseCount((count) => Math.min(count + 5, savedCourses.length)); + }; + + if (selectedCourse) { + return ( +
+ setSelectedCourse(null)} /> + + +
+ ); + } + + if (selectedPlace) { + return ( +
+ setSelectedPlace(null)} /> + + +
+ ); + } + + if (view === "places") { + return ( +
+ setView("main")} onChangePlaces={setPlaces} onSelectPlace={setSelectedPlace} /> + + +
+ ); + } + + if (view === "courses") { + return ( +
+ setView("main")} onSelectCourse={setSelectedCourse} /> + + +
+ ); + } return ( -
-
+
+
+ + +
+ ({ id: place.id, name: place.name }))} + onOpenPlaces={() => setView("places")} + /> + + setView("courses")} + onSelectCourse={setSelectedCourse} + /> + + +
+
+
From ba812b0e76ff1c4ea4ca48b4c0144b66951e7687 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Thu, 30 Apr 2026 23:51:21 +0900 Subject: [PATCH 2/3] fix: npm run lint:fix --- src/app/router/index.tsx | 3 -- src/components/mypage/MyPageCourseDetail.tsx | 30 ++++++++---- src/components/mypage/MyPlaceSummaryCard.tsx | 8 ++- src/components/mypage/MySavedPlacesPage.tsx | 20 ++++++-- src/components/mypage/SavedCourseCard.tsx | 14 ++++-- src/components/mypage/SavedCourseListPage.tsx | 49 ++++++++++++++----- .../mypage/SavedPlaceCategoryTabs.tsx | 6 ++- .../mypage/SavedPlaceDetailPage.tsx | 48 +++++++++--------- src/components/mypage/SavedPlaceItem.tsx | 15 ++++-- .../mypage/SavedPlaceMemoEditor.tsx | 14 +++++- src/pages/tabs/MyPage.tsx | 13 ++++- 11 files changed, 155 insertions(+), 65 deletions(-) diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 36d90b9..ca69e43 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -73,6 +73,3 @@ export const router = createBrowserRouter([ ], }, ]); - - - diff --git a/src/components/mypage/MyPageCourseDetail.tsx b/src/components/mypage/MyPageCourseDetail.tsx index b6d759a..b904032 100644 --- a/src/components/mypage/MyPageCourseDetail.tsx +++ b/src/components/mypage/MyPageCourseDetail.tsx @@ -12,35 +12,47 @@ export function MyPageCourseDetail({ course, onBack }: MyPageCourseDetailProps)
-
-
-
+
+
+
{["left-20 top-24", "left-36 top-16", "left-48 top-32"].map((position, index) => ( - + {index + 1} ))}
-
+
- -

{course.title}

+

+ {course.title} +

    {course.stops.map((stop) => (
  1. - +

    {stop.name}

    {stop.address}

    - diff --git a/src/components/mypage/MyPlaceSummaryCard.tsx b/src/components/mypage/MyPlaceSummaryCard.tsx index a212ef1..30c94df 100644 --- a/src/components/mypage/MyPlaceSummaryCard.tsx +++ b/src/components/mypage/MyPlaceSummaryCard.tsx @@ -34,7 +34,9 @@ export function MyPlaceSummaryCard({ -

    {formatCount(totalCount)}개

    +

    + {formatCount(totalCount)}개 +

    최근 저장 장소

    @@ -46,7 +48,9 @@ export function MyPlaceSummaryCard({

    )) ) : ( -

    나의 장소를 저장해보세요!

    +

    + 나의 장소를 저장해보세요! +

    )}
diff --git a/src/components/mypage/MySavedPlacesPage.tsx b/src/components/mypage/MySavedPlacesPage.tsx index 943c022..67c0383 100644 --- a/src/components/mypage/MySavedPlacesPage.tsx +++ b/src/components/mypage/MySavedPlacesPage.tsx @@ -16,7 +16,12 @@ function formatCount(count: number) { return count > 999 ? "999+" : String(count); } -export function MySavedPlacesPage({ places, onBack, onChangePlaces, onSelectPlace }: MySavedPlacesPageProps) { +export function MySavedPlacesPage({ + places, + onBack, + onChangePlaces, + onSelectPlace, +}: MySavedPlacesPageProps) { const [selectedFilter, setSelectedFilter] = useState("all"); const [openMenuId, setOpenMenuId] = useState(null); const [editingPlaceId, setEditingPlaceId] = useState(null); @@ -30,7 +35,8 @@ export function MySavedPlacesPage({ places, onBack, onChangePlaces, onSelectPlac return places.filter((place) => place.category === selectedFilter); }, [places, selectedFilter]); - const emptyMessage = places.length === 0 ? "나의 장소를 저장해보세요!" : "해당하는 장소가 없습니다."; + const emptyMessage = + places.length === 0 ? "나의 장소를 저장해보세요!" : "해당하는 장소가 없습니다."; const handleStartMemo = (place: SavedPlace) => { setOpenMenuId(null); @@ -71,12 +77,18 @@ export function MySavedPlacesPage({ places, onBack, onChangePlaces, onSelectPlac
-

나의 장소

- 총 {formatCount(places.length)}개 + + 총 {formatCount(places.length)}개 +
diff --git a/src/components/mypage/SavedCourseCard.tsx b/src/components/mypage/SavedCourseCard.tsx index 8056065..61af1c5 100644 --- a/src/components/mypage/SavedCourseCard.tsx +++ b/src/components/mypage/SavedCourseCard.tsx @@ -25,12 +25,20 @@ export function SavedCourseCard({ course, onSelect, className }: SavedCourseCard )} > - {isFriendCourse ? 친구 : } + {isFriendCourse ? ( + 친구 + ) : ( + + )} - {course.title} - {course.executedAtLabel} + + {course.title} + + + {course.executedAtLabel} + diff --git a/src/components/mypage/SavedCourseListPage.tsx b/src/components/mypage/SavedCourseListPage.tsx index 9f21974..1652003 100644 --- a/src/components/mypage/SavedCourseListPage.tsx +++ b/src/components/mypage/SavedCourseListPage.tsx @@ -59,7 +59,9 @@ export function SavedCourseListPage({ courses, onBack, onSelectCourse }: SavedCo const handleToggleRoom = (room: string) => { setSelectedFilter("room"); - setSelectedRooms((current) => (current.includes(room) ? current.filter((item) => item !== room) : [...current, room])); + setSelectedRooms((current) => + current.includes(room) ? current.filter((item) => item !== room) : [...current, room], + ); }; const handleSelectDate = (date: number) => { @@ -72,12 +74,20 @@ export function SavedCourseListPage({ courses, onBack, onSelectCourse }: SavedCo
- -

저장된 데이트 코스

- 총 {formatCount(visibleCourses.length)}개 +

+ 저장된 데이트 코스 +

+ + 총 {formatCount(visibleCourses.length)}개 +
@@ -86,7 +96,9 @@ export function SavedCourseListPage({ courses, onBack, onSelectCourse }: SavedCo onClick={handleSelectAll} className={cn( "flex h-8 shrink-0 items-center rounded-full border px-4 text-xs font-semibold transition-colors", - selectedFilter === "all" ? "border-[#e6e6e6] bg-[#eeeeee] text-[#111111]" : "border-[#e5e5e5] bg-white text-[#222222] active:bg-[#f7f7f7]", + selectedFilter === "all" + ? "border-[#e6e6e6] bg-[#eeeeee] text-[#111111]" + : "border-[#e5e5e5] bg-white text-[#222222] active:bg-[#f7f7f7]", )} > 전체 @@ -101,7 +113,9 @@ export function SavedCourseListPage({ courses, onBack, onSelectCourse }: SavedCo }} className={cn( "flex h-8 shrink-0 items-center gap-1 rounded-full border px-4 text-xs font-semibold transition-colors", - selectedFilter === "room" ? "border-[#e6e6e6] bg-[#eeeeee] text-[#111111]" : "border-[#e5e5e5] bg-white text-[#222222] active:bg-[#f7f7f7]", + selectedFilter === "room" + ? "border-[#e6e6e6] bg-[#eeeeee] text-[#111111]" + : "border-[#e5e5e5] bg-white text-[#222222] active:bg-[#f7f7f7]", )} > 방 @@ -109,11 +123,14 @@ export function SavedCourseListPage({ courses, onBack, onSelectCourse }: SavedCo {openPopup === "room" ? ( -
+
{rooms.map((room) => { const checked = selectedRooms.includes(room); return ( -
diff --git a/src/components/mypage/SavedPlaceCategoryTabs.tsx b/src/components/mypage/SavedPlaceCategoryTabs.tsx index c4a3d85..c6aca19 100644 --- a/src/components/mypage/SavedPlaceCategoryTabs.tsx +++ b/src/components/mypage/SavedPlaceCategoryTabs.tsx @@ -19,7 +19,11 @@ type SavedPlaceCategoryTabsProps = { export function SavedPlaceCategoryTabs({ selected, onSelect }: SavedPlaceCategoryTabsProps) { return ( -
+
{filters.map((filter) => { const active = selected === filter.id; return ( diff --git a/src/components/mypage/SavedPlaceDetailPage.tsx b/src/components/mypage/SavedPlaceDetailPage.tsx index 50c296a..ce860b3 100644 --- a/src/components/mypage/SavedPlaceDetailPage.tsx +++ b/src/components/mypage/SavedPlaceDetailPage.tsx @@ -34,25 +34,23 @@ export function SavedPlaceDetailPage({ place, onBack }: SavedPlaceDetailPageProp
-
-
-
-
+
+
+
+
- {[ - "left-[22%] top-[48%]", - "left-[50%] top-[35%]", - "right-[18%] top-[54%]", - ].map((position) => ( - - - - ))} + {["left-[22%] top-[48%]", "left-[50%] top-[35%]", "right-[18%] top-[54%]"].map( + (position) => ( + + + + ), + )} -
+
{TEXT.mapTitle} @@ -60,18 +58,24 @@ export function SavedPlaceDetailPage({ place, onBack }: SavedPlaceDetailPageProp
-
+
- -

{place.name}

+

+ {place.name} +

-

{place.address}

+

{place.address}

); -} \ No newline at end of file +} diff --git a/src/components/mypage/SavedPlaceItem.tsx b/src/components/mypage/SavedPlaceItem.tsx index 8512e75..262e307 100644 --- a/src/components/mypage/SavedPlaceItem.tsx +++ b/src/components/mypage/SavedPlaceItem.tsx @@ -41,7 +41,7 @@ export function SavedPlaceItem({
{place.memo && !isEditing ? ( -

{place.memo}

+

+ {place.memo} +

) : null} {isEditing ? ( - + ) : null} {isMenuOpen ? ( -
+
diff --git a/src/pages/tabs/MyPage.tsx b/src/pages/tabs/MyPage.tsx index 3a2a63b..0dc7ef4 100644 --- a/src/pages/tabs/MyPage.tsx +++ b/src/pages/tabs/MyPage.tsx @@ -56,7 +56,12 @@ export default function MyPage() { if (view === "places") { return (
- setView("main")} onChangePlaces={setPlaces} onSelectPlace={setSelectedPlace} /> + setView("main")} + onChangePlaces={setPlaces} + onSelectPlace={setSelectedPlace} + />
@@ -66,7 +71,11 @@ export default function MyPage() { if (view === "courses") { return (
- setView("main")} onSelectCourse={setSelectedCourse} /> + setView("main")} + onSelectCourse={setSelectedCourse} + />
From 09c3ca8408f72b5056598a25ddd12edbe2738d0f Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Fri, 1 May 2026 01:42:06 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20UI/UX=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../course-planner/CoursePlaceInfoPanel.tsx | 92 ++-- .../course-planner/CoursePlaceTagSelector.tsx | 63 ++- .../CoursePlannerBottomSheet.tsx | 7 +- .../course-planner/DateTimeSelectionPanel.tsx | 9 +- src/components/map/FilterBar.tsx | 26 +- .../map/filters/map-filter-bar-props.ts | 2 + src/components/mypage/MyAccountActions.tsx | 2 +- src/components/mypage/MyPageCourseDetail.tsx | 71 --- src/components/mypage/MyPlaceSummaryCard.tsx | 7 +- src/components/mypage/MyProfileHeader.tsx | 45 +- src/components/mypage/MySavedCoursesPage.tsx | 488 ++++++++++++++++++ src/components/mypage/MySavedPlacesPage.tsx | 284 ++++++++-- src/components/mypage/SavedCourseListPage.tsx | 223 -------- src/components/mypage/SavedCourseSection.tsx | 55 +- .../mypage/SavedPlaceCategoryTabs.tsx | 49 -- .../mypage/SavedPlaceDetailPage.tsx | 111 ---- src/components/mypage/SavedPlaceItem.tsx | 69 +-- .../mypage/SavedPlaceMemoEditor.tsx | 80 ++- .../mypage/map-places-from-my-saved.ts | 24 + src/components/mypage/mypage-mock-data.ts | 173 +++---- .../mypage/saved-course-planner-map.ts | 54 ++ src/components/room/EditRoomNameModal.tsx | 6 +- src/components/room/FriendRoomItemView.tsx | 2 +- src/components/room/RoomMainHeader.tsx | 38 +- src/components/room/RoomMainShell.tsx | 11 +- src/components/ui/BottomSheet.tsx | 18 +- .../map/hooks/use-map-search-filters.ts | 85 ++- .../map/lib/fallback-place-filter-data.ts | 68 +++ .../room/link-add/mock-link-processing.ts | 46 +- src/hooks/use-place-detail-open-event.ts | 25 + src/index.css | 2 +- src/pages/map/MapHomePage.tsx | 2 +- src/pages/map/map-home-mock.ts | 250 ++++++--- src/pages/tabs/CoursePlannerPage.tsx | 177 +++++-- src/pages/tabs/MyPage.tsx | 155 ++++-- src/pages/tabs/PlaceListPage.tsx | 9 +- 36 files changed, 1786 insertions(+), 1042 deletions(-) delete mode 100644 src/components/mypage/MyPageCourseDetail.tsx create mode 100644 src/components/mypage/MySavedCoursesPage.tsx delete mode 100644 src/components/mypage/SavedCourseListPage.tsx delete mode 100644 src/components/mypage/SavedPlaceCategoryTabs.tsx delete mode 100644 src/components/mypage/SavedPlaceDetailPage.tsx create mode 100644 src/components/mypage/map-places-from-my-saved.ts create mode 100644 src/components/mypage/saved-course-planner-map.ts create mode 100644 src/features/map/lib/fallback-place-filter-data.ts create mode 100644 src/hooks/use-place-detail-open-event.ts diff --git a/src/components/course-planner/CoursePlaceInfoPanel.tsx b/src/components/course-planner/CoursePlaceInfoPanel.tsx index debe5b3..b4fdb35 100644 --- a/src/components/course-planner/CoursePlaceInfoPanel.tsx +++ b/src/components/course-planner/CoursePlaceInfoPanel.tsx @@ -39,6 +39,8 @@ type CoursePlaceInfoPanelProps = { onBack: () => void; /** `fromEditMode`: 편집 후 저장이면 상위에서 상세 화면만 갱신, 아니면 신규 저장 플로우(예: 플래너 초기화) */ onSave: (nextTitle: string, nextStops: CourseStop[], fromEditMode: boolean) => void; + /** true면 조회 모드에서 「데이트코스 저장하기」 버튼을 숨김 — 이미 저장된 코스(마이 페이지 등) */ + hideNewCourseSaveButton?: boolean; }; export function CoursePlaceInfoPanel({ @@ -46,6 +48,7 @@ export function CoursePlaceInfoPanel({ stops, onBack, onSave, + hideNewCourseSaveButton = false, }: CoursePlaceInfoPanelProps) { const openDetail = usePlaceDetailStore((s) => s.openDetail); @@ -104,49 +107,50 @@ export function CoursePlaceInfoPanel({ const displayStops = isEditing ? draftStops : stops; return ( -
-
- +
+ {isEditing ? ( +
+ setDraftTitle(event.target.value)} + className="text-foreground placeholder:text-muted-foreground border-border focus:border-primary w-full border-b bg-transparent pt-0.5 pb-2 text-lg leading-snug font-semibold transition-colors outline-none" + placeholder="코스 이름" + aria-label="코스 이름 편집" + /> +
+ ) : ( +
+ -
- {isEditing ? ( - setDraftTitle(event.target.value)} - className="text-foreground placeholder:text-muted-foreground border-border focus:border-primary min-w-0 flex-1 shrink border-b bg-transparent pt-0.5 pb-2 text-lg leading-snug font-semibold transition-colors outline-none" - placeholder="코스 이름" - aria-label="코스 이름 편집" - /> - ) : ( - <> -

- {courseTitle} -

- - - )} -
-
+
+

+ {courseTitle} +

+ +
+
+ )}
{isEditing ? ( @@ -214,7 +218,7 @@ export function CoursePlaceInfoPanel({
{isEditing ? ( -
+
- ) : ( + ) : hideNewCourseSaveButton ? null : (
diff --git a/src/components/course-planner/CoursePlannerBottomSheet.tsx b/src/components/course-planner/CoursePlannerBottomSheet.tsx index 498a613..96abedd 100644 --- a/src/components/course-planner/CoursePlannerBottomSheet.tsx +++ b/src/components/course-planner/CoursePlannerBottomSheet.tsx @@ -14,12 +14,7 @@ export function CoursePlannerBottomSheet({ children, }: CoursePlannerBottomSheetProps) { return ( - + {children} ); diff --git a/src/components/course-planner/DateTimeSelectionPanel.tsx b/src/components/course-planner/DateTimeSelectionPanel.tsx index b5dd183..05bfcc3 100644 --- a/src/components/course-planner/DateTimeSelectionPanel.tsx +++ b/src/components/course-planner/DateTimeSelectionPanel.tsx @@ -45,10 +45,15 @@ function getMonthMatrixBase(date: Date) { type DateCalendarPanelProps = { selectedDate: string | null; onSelectDate: (date: string) => void; + className?: string; }; /** 날짜만 선택하는 캘린더 카드 */ -export function DateCalendarPanel({ selectedDate, onSelectDate }: DateCalendarPanelProps) { +export function DateCalendarPanel({ + selectedDate, + onSelectDate, + className, +}: DateCalendarPanelProps) { const parsedAnchorDate = useMemo(() => parseDateAnchor(selectedDate), [selectedDate]); const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(parsedAnchorDate)); @@ -72,7 +77,7 @@ export function DateCalendarPanel({ selectedDate, onSelectDate }: DateCalendarPa }; return ( -
+
) : null} - + {!hideTagPanel ? ( + + ) : null}
); } diff --git a/src/components/map/filters/map-filter-bar-props.ts b/src/components/map/filters/map-filter-bar-props.ts index d71863f..1a8e80f 100644 --- a/src/components/map/filters/map-filter-bar-props.ts +++ b/src/components/map/filters/map-filter-bar-props.ts @@ -3,6 +3,8 @@ import type { MapCategoryFilterChip, MapPrimaryCategory } from "@/shared/types/m /** 지도 검색 오버레이 내부에서 사용하는 FilterBar/Panel의 렌더링 상태 Props */ export type MapFilterBarProps = { + /** true면 상세 태그 패널을 렌더하지 않고 카테고리 칩만 표시한다. */ + hideTagPanel?: boolean; categories: MapCategoryFilterChip[]; categoryNameByCode: Record; filterCategories: Category[]; diff --git a/src/components/mypage/MyAccountActions.tsx b/src/components/mypage/MyAccountActions.tsx index 2bcc02c..ef6ff94 100644 --- a/src/components/mypage/MyAccountActions.tsx +++ b/src/components/mypage/MyAccountActions.tsx @@ -5,7 +5,7 @@ export function MyAccountActions({ onLogout, onWithdraw }: MyAccountActionsProps) { return ( -
+
-

- {course.title} -

- -
- -
    - {course.stops.map((stop) => ( -
  1. - -

    {stop.name}

    -

    {stop.address}

    - - {stop.walkingTime ? ( -

    - - {stop.walkingTime} -

    - ) : null} -
  2. - ))} -
-
- - ); -} diff --git a/src/components/mypage/MyPlaceSummaryCard.tsx b/src/components/mypage/MyPlaceSummaryCard.tsx index 30c94df..6882e7b 100644 --- a/src/components/mypage/MyPlaceSummaryCard.tsx +++ b/src/components/mypage/MyPlaceSummaryCard.tsx @@ -28,10 +28,13 @@ export function MyPlaceSummaryCard({

diff --git a/src/components/mypage/MyProfileHeader.tsx b/src/components/mypage/MyProfileHeader.tsx index 46fbca4..d8d578f 100644 --- a/src/components/mypage/MyProfileHeader.tsx +++ b/src/components/mypage/MyProfileHeader.tsx @@ -1,16 +1,47 @@ -import { UserRound } from "lucide-react"; +import { User } from "lucide-react"; +import { useCallback, useState } from "react"; + +import { cn } from "@/lib/utils"; type MyProfileHeaderProps = { nickname: string; + profileImageUrl?: string | null; + className?: string; }; -export function MyProfileHeader({ nickname }: MyProfileHeaderProps) { +export function MyProfileHeader({ nickname, profileImageUrl, className }: MyProfileHeaderProps) { + const url = profileImageUrl?.trim() ?? ""; + const [failedUrl, setFailedUrl] = useState(null); + const showImage = Boolean(url) && failedUrl !== url; + + const handleImageError = useCallback(() => { + setFailedUrl(url); + }, [url]); + return ( -

- - - -

{nickname}님

+
+ {showImage ? ( + + ) : ( + + + + )} +

{nickname}님

); } diff --git a/src/components/mypage/MySavedCoursesPage.tsx b/src/components/mypage/MySavedCoursesPage.tsx new file mode 100644 index 0000000..e5949ed --- /dev/null +++ b/src/components/mypage/MySavedCoursesPage.tsx @@ -0,0 +1,488 @@ +import { AlertCircle, ArrowLeft, Check, ChevronDown, Pin, User } from "lucide-react"; +import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react"; + +import { + CoursePlaceInfoPanel, + type CourseStop as PlannerCourseStop, +} from "@/components/course-planner/CoursePlaceInfoPanel"; +import { CoursePlannerBottomSheet } from "@/components/course-planner/CoursePlannerBottomSheet"; +import { DateCalendarPanel } from "@/components/course-planner/DateTimeSelectionPanel"; +import { + MAP_CHIP_BASE_CLASS, + MAP_CHIP_SELECTED_CLASS, + MAP_CHIP_UNSELECTED_CLASS, + MAP_FILTER_PANEL_BASE_CLASS, +} from "@/components/map/chip-style"; +import { weightedMapCenter } from "@/components/mypage/map-places-from-my-saved"; +import type { SavedCourse, SavedPlace } from "@/components/mypage/mypage-mock-data"; +import { + mapPlacesFromSavedCourses, + savedCourseToPlannerStops, +} from "@/components/mypage/saved-course-planner-map"; +import { SavedCourseCard } from "@/components/mypage/SavedCourseCard"; +import { useRoomsQuery } from "@/features/room"; +import { usePointerDownOutside } from "@/hooks/use-pointer-down-outside"; +import { cn } from "@/lib/utils"; +import { MAP_INITIAL_CENTER } from "@/pages/map/map-home-mock"; +import { resolveSavedPlacesBusinessHours, useKoreanNow } from "@/shared/lib/place-business-hours"; +import { usePlaceDetailStore } from "@/store/placeDetailStore"; + +const KAKAO_MAP_APP_KEY = import.meta.env.VITE_KAKAO_MAP_APP_KEY; +const KakaoMapView = lazy(() => + import("@/components/map/KakaoMapView").then((module) => ({ default: module.KakaoMapView })), +); + +type CourseFilter = "all" | "room" | "date"; +type FilterPopup = Exclude | null; + +function formatCount(count: number) { + return count > 999 ? "999+" : String(count); +} + +function formatDateLabel(date: string | null) { + return date ?? "날짜"; +} + +function filterChipClass(active: boolean) { + return cn(MAP_CHIP_BASE_CLASS, active ? MAP_CHIP_SELECTED_CLASS : MAP_CHIP_UNSELECTED_CLASS); +} + +type MySavedCoursesPageProps = { + courses: SavedCourse[]; + savedPlaces: SavedPlace[]; + selectedCourse: SavedCourse | null; + onSelectCourse: (course: SavedCourse) => void; + onCloseCourseSheet: () => void; + onBack: () => void; + onPersistCourse: ( + prevCourseId: string, + nextTitle: string, + nextStops: PlannerCourseStop[], + fromEditMode: boolean, + ) => void; +}; + +export function MySavedCoursesPage({ + courses, + savedPlaces, + selectedCourse, + onSelectCourse, + onCloseCourseSheet, + onBack, + onPersistCourse, +}: MySavedCoursesPageProps) { + const now = useKoreanNow(); + const { data: roomsFromApi, isLoading: isRoomsLoading } = useRoomsQuery(); + const [selectedFilter, setSelectedFilter] = useState("all"); + const [openPopup, setOpenPopup] = useState(null); + const [selectedRoomIds, setSelectedRoomIds] = useState([]); + const [selectedDate, setSelectedDate] = useState(null); + + const roomChipApplied = selectedRoomIds.length > 0; + const dateChipApplied = selectedDate !== null; + const allChipActive = !roomChipApplied && !dateChipApplied; + + const detailOpen = usePlaceDetailStore((s) => s.isOpen); + const selectedPlaceId = usePlaceDetailStore((s) => s.selectedPlaceId); + const closeDetail = usePlaceDetailStore((s) => s.closeDetail); + + /** 나의 장소와 동일: 목록에서는 지도 미표시, 코스 바텀시트·장소 상세 때만 지도 */ + const overlayMapOpen = Boolean(selectedCourse) || detailOpen; + + const filterChromeRef = useRef(null); + + const closeFilterPopups = useCallback(() => { + setOpenPopup(null); + setSelectedFilter((prev) => { + if (prev === "date" && selectedDate === null) return "all"; + if (prev === "room" && selectedRoomIds.length === 0) return "all"; + return prev; + }); + }, [selectedDate, selectedRoomIds]); + + usePointerDownOutside(filterChromeRef, openPopup !== null && !overlayMapOpen, closeFilterPopups); + + const roomsList = useMemo(() => { + const list = roomsFromApi ?? []; + return [...list].sort((a, b) => Number(b.pinned) - Number(a.pinned)); + }, [roomsFromApi]); + + const coursesHaveRoomLink = useMemo( + () => courses.some((c) => Boolean(c.savedFromRoomId)), + [courses], + ); + + const visibleCourses = useMemo(() => { + if (selectedFilter === "date" && selectedDate === "2025.04.26") { + return []; + } + + if (selectedFilter === "date" && selectedDate) { + return courses.slice(0, 4); + } + + if (selectedFilter === "room") { + if (!coursesHaveRoomLink) { + return courses; + } + if (selectedRoomIds.length === 0) { + return courses; + } + return courses.filter( + (c) => c.savedFromRoomId != null && selectedRoomIds.includes(c.savedFromRoomId), + ); + } + + return courses; + }, [courses, coursesHaveRoomLink, selectedDate, selectedFilter, selectedRoomIds]); + + const mapPins = useMemo(() => { + const raw = selectedCourse + ? mapPlacesFromSavedCourses([selectedCourse], savedPlaces) + : mapPlacesFromSavedCourses(visibleCourses, savedPlaces); + return resolveSavedPlacesBusinessHours(raw, now); + }, [now, savedPlaces, selectedCourse, visibleCourses]); + + const mapCenter = useMemo(() => { + if (detailOpen && selectedPlaceId) { + const pin = mapPins.find((p) => p.id === selectedPlaceId); + if (pin) return { latitude: pin.latitude, longitude: pin.longitude }; + } + if (selectedCourse) { + const focused = mapPlacesFromSavedCourses([selectedCourse], savedPlaces); + if (focused.length > 0) return weightedMapCenter(focused); + } + return weightedMapCenter(mapPins); + }, [detailOpen, mapPins, savedPlaces, selectedCourse, selectedPlaceId]); + + const handleSelectAll = () => { + setSelectedFilter("all"); + setOpenPopup(null); + setSelectedRoomIds([]); + setSelectedDate(null); + }; + + const handleToggleRoom = (roomId: string) => { + const nextIds = selectedRoomIds.includes(roomId) + ? selectedRoomIds.filter((item) => item !== roomId) + : [...selectedRoomIds, roomId]; + setSelectedRoomIds(nextIds); + setSelectedFilter(nextIds.length === 0 ? "all" : "room"); + }; + + const handlePickCalendarDate = (dateStr: string) => { + if (selectedDate === dateStr) { + setSelectedDate(null); + setSelectedFilter("all"); + setOpenPopup(null); + return; + } + setSelectedFilter("date"); + setSelectedDate(dateStr); + setOpenPopup(null); + }; + + const handleHeaderBack = () => { + if (detailOpen) { + closeDetail(); + return; + } + if (selectedCourse) { + onCloseCourseSheet(); + return; + } + onBack(); + }; + + const headerBackdrop = overlayMapOpen + ? "border-border/55 bg-background/93 supports-[backdrop-filter]:bg-background/82 border-b border-transparent shadow-[0_8px_24px_oklch(0_0_0/0.05)] backdrop-blur-md backdrop-saturate-150" + : "bg-background sticky top-0"; + + return ( +
+ {overlayMapOpen ? ( +
+ }> + 0 ? mapCenter : MAP_INITIAL_CENTER} + className="h-full w-full" + /> + +
+ ) : null} + +
+
+ +

+ 저장된 데이트 코스 +

+ + 총 {formatCount(visibleCourses.length)}개 + +
+ + {!overlayMapOpen ? ( +
+ + +
+ + + {openPopup === "room" ? ( +
+ {isRoomsLoading ? ( +
+ {Array.from({ length: 4 }, (_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) : roomsList.length === 0 ? ( +
+
+ +
+

참여 중인 방이 없습니다.

+

+ 방에 참여하면 여기서 코스를 방별로 모아볼 수 있어요. +

+
+ ) : ( +
    + {roomsList.map((room) => { + const checked = selectedRoomIds.includes(room.roomId); + const members = Math.max(1, room.memberCount ?? 1); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ) : null} +
+ +
+ + + {openPopup === "date" ? ( +
+ +
+ ) : null} +
+
+ ) : null} +
+ + {!overlayMapOpen ? ( +
+ {visibleCourses.length > 0 ? ( +
+ {visibleCourses.map((course) => ( + + ))} +
+ ) : ( +
+ + + +

+ 해당하는 데이트 코스가 없습니다. +

+
+ )} +
+ ) : null} + + + {selectedCourse ? ( + + onPersistCourse(selectedCourse.id, nextTitle, nextStops, fromEditMode) + } + /> + ) : null} + +
+ ); +} diff --git a/src/components/mypage/MySavedPlacesPage.tsx b/src/components/mypage/MySavedPlacesPage.tsx index 67c0383..1946944 100644 --- a/src/components/mypage/MySavedPlacesPage.tsx +++ b/src/components/mypage/MySavedPlacesPage.tsx @@ -1,10 +1,37 @@ -import { AlertCircle, ArrowLeft } from "lucide-react"; -import { useMemo, useState } from "react"; +import "@/components/map/filter-bar.css"; + +import { AlertCircle, ArrowLeft } from "lucide-react"; +import { lazy, Suspense, useMemo, useRef, useState } from "react"; + +import { FilterBar } from "@/components/map/FilterBar"; +import { + mapPlacesMatchingMySaved, + weightedMapCenter, +} from "@/components/mypage/map-places-from-my-saved"; +import { useMapSearchFilters } from "@/features/map/hooks/use-map-search-filters"; +import { usePlaceFilterData } from "@/features/map/hooks/use-place-filter-data"; +import { FALLBACK_PLACE_FILTER_DATA } from "@/features/map/lib/fallback-place-filter-data"; +import { usePointerDownOutside } from "@/hooks/use-pointer-down-outside"; +import { cn } from "@/lib/utils"; +import { SAVED_PLACE_MOCKS } from "@/pages/map/map-home-mock"; +import { resolveSavedPlacesBusinessHours, useKoreanNow } from "@/shared/lib/place-business-hours"; +import { + MAP_ALL_CATEGORY_FILTER_CHIP, + type MapPrimaryCategory, + type SavedPlace as MapSavedPlace, +} from "@/shared/types/map-home"; +import { usePlaceDetailStore } from "@/store/placeDetailStore"; import type { SavedPlace } from "./mypage-mock-data"; -import { SavedPlaceCategoryTabs, type SavedPlaceFilter } from "./SavedPlaceCategoryTabs"; import { SavedPlaceItem } from "./SavedPlaceItem"; +const KAKAO_MAP_APP_KEY = import.meta.env.VITE_KAKAO_MAP_APP_KEY; +const KakaoMapView = lazy(() => + import("@/components/map/KakaoMapView").then((module) => ({ default: module.KakaoMapView })), +); + +const MOCK_BY_ID = new Map(SAVED_PLACE_MOCKS.map((place) => [place.id, place])); + type MySavedPlacesPageProps = { places: SavedPlace[]; onBack: () => void; @@ -22,18 +49,105 @@ export function MySavedPlacesPage({ onChangePlaces, onSelectPlace, }: MySavedPlacesPageProps) { - const [selectedFilter, setSelectedFilter] = useState("all"); + const now = useKoreanNow(); + const { + filterCategories: apiFilterCategories, + isInitialLoading: isTaxonomyLoading, + isInitialError: isTaxonomyError, + retryLoad, + } = usePlaceFilterData(); + + const filterCategories = useMemo(() => { + if (apiFilterCategories.length > 0) return apiFilterCategories; + if (isTaxonomyLoading && !isTaxonomyError) return []; + return FALLBACK_PLACE_FILTER_DATA.categories; + }, [apiFilterCategories, isTaxonomyError, isTaxonomyLoading]); + + const categories = useMemo( + () => [MAP_ALL_CATEGORY_FILTER_CHIP, ...filterCategories.map((category) => category.code)], + [filterCategories], + ); + + const categoryNameByCode = useMemo( + () => + filterCategories.reduce( + (accumulator, category) => { + accumulator[category.code as MapPrimaryCategory] = category.name; + return accumulator; + }, + {} as Record, + ), + [filterCategories], + ); + + const isCategoryLoading = filterCategories.length === 0 && isTaxonomyLoading && !isTaxonomyError; + + const mergedForFilter = useMemo( + (): MapSavedPlace[] => + places.map((place) => { + const mock = MOCK_BY_ID.get(place.id); + return { + id: place.id, + name: place.name, + address: place.address, + category: place.category, + tagKeys: place.tagKeys ?? mock?.tagKeys, + latitude: mock?.latitude ?? 0, + longitude: mock?.longitude ?? 0, + reelsUrl: mock?.reelsUrl, + businessHours: mock?.businessHours, + }; + }), + [places], + ); + + const { + activeCategories, + focusedCategory, + toggleCategory, + closeTagPanel, + isTagPanelOpen, + selectedTagKeysByCategory, + selectedTagCountByCategory, + toggleTagInCategory, + resetFocusedCategoryTags, + filteredPlaces, + } = useMapSearchFilters({ + places: mergedForFilter, + filterCategories, + categoriesOnly: true, + }); + + const listPlaces = useMemo(() => { + const allow = new Set(filteredPlaces.map((place) => place.id)); + return places.filter((place) => allow.has(place.id)); + }, [filteredPlaces, places]); + + const mapPins = useMemo( + () => resolveSavedPlacesBusinessHours(mapPlacesMatchingMySaved(listPlaces), now), + [listPlaces, now], + ); + const [openMenuId, setOpenMenuId] = useState(null); const [editingPlaceId, setEditingPlaceId] = useState(null); const [memoDraft, setMemoDraft] = useState(""); - const filteredPlaces = useMemo(() => { - if (selectedFilter === "all") { - return places; - } + const detailOpen = usePlaceDetailStore((s) => s.isOpen); + const selectedPlaceId = usePlaceDetailStore((s) => s.selectedPlaceId); + const closeDetail = usePlaceDetailStore((s) => s.closeDetail); + + const filterChromeRef = useRef(null); + usePointerDownOutside(filterChromeRef, isTagPanelOpen, closeTagPanel); - return places.filter((place) => place.category === selectedFilter); - }, [places, selectedFilter]); + const mapCenter = useMemo(() => { + if (selectedPlaceId) { + const pin = mapPins.find((p) => p.id === selectedPlaceId); + if (pin) { + return { latitude: pin.latitude, longitude: pin.longitude }; + } + } + return weightedMapCenter(mapPins); + }, [mapPins, selectedPlaceId]); const emptyMessage = places.length === 0 ? "나의 장소를 저장해보세요!" : "해당하는 장소가 없습니다."; @@ -52,12 +166,7 @@ export function MySavedPlacesPage({ const nextMemo = memoDraft.trim(); onChangePlaces( places.map((place) => - place.id === editingPlaceId - ? { - ...place, - memo: nextMemo || undefined, - } - : place, + place.id === editingPlaceId ? { ...place, memo: nextMemo || undefined } : place, ), ); setEditingPlaceId(null); @@ -73,56 +182,123 @@ export function MySavedPlacesPage({ } }; + const handleHeaderBack = () => { + if (detailOpen) { + closeDetail(); + return; + } + onBack(); + }; + + const displayedCountLabel = + listPlaces.length === places.length + ? `${formatCount(places.length)}개` + : `${formatCount(listPlaces.length)}개 · 전체 ${formatCount(places.length)}`; + return ( -
-
+
+ {detailOpen ? ( +
+ }> + + +
+ ) : null} + +
-

나의 장소

- - 총 {formatCount(places.length)}개 +

+ 나의 장소 +

+ + {displayedCountLabel}
- + {!detailOpen ? ( +
+ { + void retryLoad(); + }} + activeCategories={activeCategories} + focusedCategory={focusedCategory} + onToggleCategory={toggleCategory} + isTagPanelOpen={isTagPanelOpen} + selectedTagKeysByCategory={selectedTagKeysByCategory} + selectedTagCountByCategory={selectedTagCountByCategory} + onToggleTagInCategory={toggleTagInCategory} + onResetFocusedCategoryTags={resetFocusedCategoryTags} + onCloseTagPanel={closeTagPanel} + /> +
+ ) : null}
-
- {filteredPlaces.length > 0 ? ( -
- {filteredPlaces.map((place) => ( - setOpenMenuId((current) => (current === id ? null : id))} - onStartMemo={handleStartMemo} - onChangeMemo={setMemoDraft} - onSaveMemo={handleSaveMemo} - onClearMemo={() => setMemoDraft("")} - onDelete={handleDeletePlace} - onSelect={onSelectPlace} - /> - ))} -
- ) : ( -
- - - -

{emptyMessage}

-
- )} -
-
+ {!detailOpen ? ( +
+ {listPlaces.length > 0 ? ( +
+ {listPlaces.map((place) => ( + setOpenMenuId((current) => (current === id ? null : id))} + onStartMemo={handleStartMemo} + onChangeMemo={setMemoDraft} + onSaveMemo={handleSaveMemo} + onClearMemo={() => setMemoDraft("")} + onDelete={handleDeletePlace} + onSelect={onSelectPlace} + /> + ))} +
+ ) : ( +
+ + + +

{emptyMessage}

+
+ )} +
+ ) : null} +
); } diff --git a/src/components/mypage/SavedCourseListPage.tsx b/src/components/mypage/SavedCourseListPage.tsx deleted file mode 100644 index 1652003..0000000 --- a/src/components/mypage/SavedCourseListPage.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { AlertCircle, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; -import { useMemo, useState } from "react"; - -import { cn } from "@/lib/utils"; - -import type { SavedCourse } from "./mypage-mock-data"; -import { SavedCourseCard } from "./SavedCourseCard"; - -type SavedCourseListPageProps = { - courses: SavedCourse[]; - onBack: () => void; - onSelectCourse: (course: SavedCourse) => void; -}; - -type CourseFilter = "all" | "room" | "date"; -type FilterPopup = Exclude | null; - -const rooms = ["친구와의 방", "연인의 방", "남친구와의 방", "친구 4, 친구 5, 친구 6..."]; -const aprilDates = Array.from({ length: 30 }, (_, index) => index + 1); - -function formatCount(count: number) { - return count > 999 ? "999+" : String(count); -} - -function formatDateLabel(date: number | null) { - return date ? `2025.04.${String(date).padStart(2, "0")}` : "날짜"; -} - -export function SavedCourseListPage({ courses, onBack, onSelectCourse }: SavedCourseListPageProps) { - const [selectedFilter, setSelectedFilter] = useState("all"); - const [openPopup, setOpenPopup] = useState(null); - const [selectedRooms, setSelectedRooms] = useState([rooms[1]]); - const [selectedDate, setSelectedDate] = useState(null); - - const visibleCourses = useMemo(() => { - if (selectedFilter === "date" && selectedDate === 26) { - return []; - } - - if (selectedFilter === "date" && selectedDate) { - return courses.slice(0, 4); - } - - if (selectedFilter === "room" && selectedRooms.length === 0) { - return []; - } - - if (selectedFilter === "room") { - return courses.slice(0, 4); - } - - return courses; - }, [courses, selectedDate, selectedFilter, selectedRooms.length]); - - const handleSelectAll = () => { - setSelectedFilter("all"); - setOpenPopup(null); - }; - - const handleToggleRoom = (room: string) => { - setSelectedFilter("room"); - setSelectedRooms((current) => - current.includes(room) ? current.filter((item) => item !== room) : [...current, room], - ); - }; - - const handleSelectDate = (date: number) => { - setSelectedFilter("date"); - setSelectedDate(date); - setOpenPopup(null); - }; - - return ( -
-
-
- -

- 저장된 데이트 코스 -

- - 총 {formatCount(visibleCourses.length)}개 - -
- -
- - -
- - - {openPopup === "room" ? ( -
- {rooms.map((room) => { - const checked = selectedRooms.includes(room); - return ( - - ); - })} -
- ) : null} -
- -
- - - {openPopup === "date" ? ( -
-
- April 2025 -
- - -
-
-
- {["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"].map((day) => ( - {day} - ))} -
-
- {aprilDates.map((date) => ( - - ))} -
-
- ) : null} -
-
-
- -
- {visibleCourses.length > 0 ? ( -
- {visibleCourses.map((course) => ( - - ))} -
- ) : ( -
- - - -

- 해당하는 데이트 코스가 없습니다. -

-
- )} -
-
- ); -} diff --git a/src/components/mypage/SavedCourseSection.tsx b/src/components/mypage/SavedCourseSection.tsx index 65694f4..a7ea102 100644 --- a/src/components/mypage/SavedCourseSection.tsx +++ b/src/components/mypage/SavedCourseSection.tsx @@ -1,49 +1,52 @@ -import type { SavedCourse } from "./mypage-mock-data"; +import { ChevronRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +import type { SavedCourse } from "./mypage-mock-data"; import { SavedCourseCard } from "./SavedCourseCard"; +const PREVIEW_COUNT = 3; + type SavedCourseSectionProps = { courses: SavedCourse[]; - visibleCount: number; - onShowMore: () => void; - onShowAll: () => void; + onOpenFullList: () => void; onSelectCourse: (course: SavedCourse) => void; }; export function SavedCourseSection({ courses, - visibleCount, - onShowMore, - onShowAll, + onOpenFullList, onSelectCourse, }: SavedCourseSectionProps) { - const visibleCourses = courses.slice(0, visibleCount); + const previewCourses = courses.slice(0, PREVIEW_COUNT); const hasCourses = courses.length > 0; - const hasHiddenCourses = visibleCount < courses.length; - const actionLabel = hasHiddenCourses ? "5개 더보기" : "코스 전체보기"; return (
-

저장된 데이트 코스

- - {hasCourses ? ( - <> -
- {visibleCourses.map((course) => ( - - ))} -
- +
+

+ 저장된 데이트 코스 +

+ {hasCourses ? ( - + ) : null} +
+ + {hasCourses ? ( +
+ {previewCourses.map((course) => ( + + ))} +
) : ( -
+
데이트 코스를 저장해보세요!
)} diff --git a/src/components/mypage/SavedPlaceCategoryTabs.tsx b/src/components/mypage/SavedPlaceCategoryTabs.tsx deleted file mode 100644 index c6aca19..0000000 --- a/src/components/mypage/SavedPlaceCategoryTabs.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { cn } from "@/lib/utils"; - -import type { SavedPlaceCategory } from "./mypage-mock-data"; - -export type SavedPlaceFilter = "all" | SavedPlaceCategory; - -const filters: { id: SavedPlaceFilter; label: string }[] = [ - { id: "all", label: "전체" }, - { id: "food", label: "맛집" }, - { id: "cafe", label: "카페" }, - { id: "activity", label: "놀거리" }, - { id: "etc", label: "기타" }, -]; - -type SavedPlaceCategoryTabsProps = { - selected: SavedPlaceFilter; - onSelect: (filter: SavedPlaceFilter) => void; -}; - -export function SavedPlaceCategoryTabs({ selected, onSelect }: SavedPlaceCategoryTabsProps) { - return ( -
- {filters.map((filter) => { - const active = selected === filter.id; - return ( - - ); - })} -
- ); -} diff --git a/src/components/mypage/SavedPlaceDetailPage.tsx b/src/components/mypage/SavedPlaceDetailPage.tsx deleted file mode 100644 index ce860b3..0000000 --- a/src/components/mypage/SavedPlaceDetailPage.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { ArrowLeft, ChevronDown, ExternalLink, MapPin } from "lucide-react"; - -import type { SavedPlace } from "./mypage-mock-data"; - -const TEXT = { - mapTitle: "\uB098\uB9CC\uC758 \uC9C0\uB3C4", - backToPlaces: "\uB098\uC758 \uC7A5\uC18C\uB85C \uB3CC\uC544\uAC00\uAE30", - reelsButton: "\uB0B4\uAC00 \uBD24\uB358 \uB9B4\uC2A4 \uB2E4\uC2DC\uBCF4\uAE30", - openedAt: "\uC601\uC5C5 \uC804 10:40 \uC624\uD508", - sharedBy: "\uACF5\uC720\uB41C \uC7A5\uC18C \uC601\uC5C5", - todayHours: "\uD1A0(4/11) 10:40 ~ 19:30", - category: "\uC885\uB958", -} as const; - -function getCategoryLabel(category: SavedPlace["category"]) { - const labels: Record = { - food: "\uB9DB\uC9D1", - cafe: "\uCE74\uD398", - activity: "\uB180\uAC70\uB9AC", - etc: "\uAE30\uD0C0", - }; - - return labels[category]; -} - -type SavedPlaceDetailPageProps = { - place: SavedPlace; - onBack: () => void; -}; - -export function SavedPlaceDetailPage({ place, onBack }: SavedPlaceDetailPageProps) { - return ( -
-
-
-
-
-
-
-
- - {["left-[22%] top-[48%]", "left-[50%] top-[35%]", "right-[18%] top-[54%]"].map( - (position) => ( - - - - ), - )} - -
-
- - {TEXT.mapTitle} -
-
-
- -
-
- -
- -

- {place.name} -

-
- -

{place.address}

- - - -
-
-
{TEXT.openedAt}
-
{TEXT.sharedBy}
-
-
-
\uC624\uB298 \uC601\uC5C5\uC2DC\uAC04
-
- {TEXT.todayHours} - -
-
-
-
{TEXT.category}
-
- {getCategoryLabel(place.category)} -
-
-
-
-
-
- ); -} diff --git a/src/components/mypage/SavedPlaceItem.tsx b/src/components/mypage/SavedPlaceItem.tsx index 262e307..796133c 100644 --- a/src/components/mypage/SavedPlaceItem.tsx +++ b/src/components/mypage/SavedPlaceItem.tsx @@ -1,4 +1,7 @@ import { MoreVertical } from "lucide-react"; +import { useCallback, useRef } from "react"; + +import { usePointerDownOutside } from "@/hooks/use-pointer-down-outside"; import type { SavedPlace } from "./mypage-mock-data"; import { SavedPlaceMemoEditor } from "./SavedPlaceMemoEditor"; @@ -30,26 +33,53 @@ export function SavedPlaceItem({ onDelete, onSelect, }: SavedPlaceItemProps) { + const menuChromeRef = useRef(null); + const closeMenu = useCallback(() => { + onToggleMenu(place.id); + }, [onToggleMenu, place.id]); + usePointerDownOutside(menuChromeRef, isMenuOpen, closeMenu); + return ( -
+
- +
+ + + {isMenuOpen ? ( +
+ + +
+ ) : null} +
{place.memo && !isEditing ? ( -

+

{place.memo}

) : null} @@ -62,25 +92,6 @@ export function SavedPlaceItem({ onClear={onClearMemo} /> ) : null} - - {isMenuOpen ? ( -
- - -
- ) : null}
); } diff --git a/src/components/mypage/SavedPlaceMemoEditor.tsx b/src/components/mypage/SavedPlaceMemoEditor.tsx index 6b2f7ca..8e5e702 100644 --- a/src/components/mypage/SavedPlaceMemoEditor.tsx +++ b/src/components/mypage/SavedPlaceMemoEditor.tsx @@ -1,5 +1,7 @@ import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + const MAX_MEMO_LENGTH = 50; type SavedPlaceMemoEditorProps = { @@ -16,34 +18,56 @@ export function SavedPlaceMemoEditor({ onClear, }: SavedPlaceMemoEditorProps) { return ( -
- onChange(event.target.value)} - onBlur={onSave} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.currentTarget.blur(); - } - }} - className="min-w-0 flex-1 bg-transparent text-xs font-medium text-[#222222] outline-none placeholder:text-[#9a9a9a]" - placeholder="메모를 남겨주세요." - autoFocus - /> - {value ? ( - - ) : null} - {value.length}/50 +
+
+ + onChange(event.target.value)} + onBlur={onSave} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + className="text-foreground placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent py-1 text-xs font-medium ring-0 outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0" + placeholder="메모를 남겨주세요." + autoFocus + /> + {value ? ( + + ) : null} +
+

+ {value.length}/{MAX_MEMO_LENGTH} +

); } diff --git a/src/components/mypage/map-places-from-my-saved.ts b/src/components/mypage/map-places-from-my-saved.ts new file mode 100644 index 0000000..6004c19 --- /dev/null +++ b/src/components/mypage/map-places-from-my-saved.ts @@ -0,0 +1,24 @@ +import { MAP_INITIAL_CENTER, SAVED_PLACE_MOCKS } from "@/pages/map/map-home-mock"; +import type { MapCoordinate, SavedPlace as MapSavedPlace } from "@/shared/types/map-home"; + +import type { SavedPlace as MySavedPlace } from "./mypage-mock-data"; + +type MapPin = Pick; + +/** 나의 장소(id)와 지도 목 목데이터가 겹치는 항목만 핀으로 사용 */ +export function mapPlacesMatchingMySaved(places: MySavedPlace[]): MapSavedPlace[] { + const ids = new Set(places.map((p) => p.id)); + return SAVED_PLACE_MOCKS.filter((m) => ids.has(m.id)); +} + +export function weightedMapCenter(mapPlaces: MapPin[]): MapCoordinate { + if (mapPlaces.length === 0) return MAP_INITIAL_CENTER; + let lat = 0; + let lng = 0; + for (const p of mapPlaces) { + lat += p.latitude; + lng += p.longitude; + } + const n = mapPlaces.length; + return { latitude: lat / n, longitude: lng / n }; +} diff --git a/src/components/mypage/mypage-mock-data.ts b/src/components/mypage/mypage-mock-data.ts index 80fc0fe..fb3a19e 100644 --- a/src/components/mypage/mypage-mock-data.ts +++ b/src/components/mypage/mypage-mock-data.ts @@ -1,4 +1,5 @@ -export type SavedPlaceCategory = "food" | "cafe" | "activity" | "etc"; +import { SAVED_PLACE_MOCKS } from "@/pages/map/map-home-mock"; +import type { MapPrimaryCategory } from "@/shared/types/map-home"; export type RecentPlace = { id: string; @@ -9,7 +10,10 @@ export type SavedPlace = { id: string; name: string; address: string; - category: SavedPlaceCategory; + /** 지도·필터와 동일한 1차 카테고리 코드 */ + category: MapPrimaryCategory; + /** `SAVED_PLACE_MOCKS`와 같은 태그 코드 */ + tagKeys?: string[]; memo?: string; }; @@ -19,6 +23,8 @@ export type SavedCourse = { executedAtLabel: string; badgeLabel: string; stops: CourseStop[]; + /** 이 코스를 저장했을 때의 방 ID(API). 없으면 방 필터는 목록 데이터가 생길 때까지 전체 표시만 적용 */ + savedFromRoomId?: string | null; }; export type CourseStop = { @@ -29,145 +35,106 @@ export type CourseStop = { hours?: string; }; -export const myPageUser = { - nickname: "홍길동", - savedPlaceCount: 58, - recentPlaces: [ - { id: "place-recent-1", name: "아임파이" }, - { id: "place-recent-2", name: "투썸플레이스" }, - ], +const MEMO_BY_PLACE_ID: Partial> = { + "cafe-2": "빵 나오는 시간 맞춰 가기", + "activity-2": "주말에는 예약 확인", }; -export const savedPlaces: SavedPlace[] = [ - { - id: "place-1", - name: "아임파이", - address: "서울 동대문구 회기로 116-1 2층 (회기동)", - category: "cafe", - }, - { - id: "place-2", - name: "감동", - address: "서울 동대문구 회기로 25길 101-13 1층", - category: "food", - memo: "커피 완전 맛있음 ★★★", - }, - { - id: "place-3", - name: "한국외국어대학교 서울캠퍼스", - address: "서울 동대문구 회기로 116-1 2층 (회기동)", - category: "etc", - }, - { - id: "place-4", - name: "언니네함박그", - address: "서울 동대문구 회기로25길 59 1층 언니네함박그", - category: "food", - }, - { - id: "place-5", - name: "무감커피바", - address: "서울 동대문구 회기로21길 19 지하1층", - category: "cafe", - }, - { - id: "place-6", - name: "호현장담 외대후문점", - address: "서울 동대문구 천장산로7길 10-1 2층 호현장담", - category: "activity", - }, -]; +export const savedPlaces: SavedPlace[] = SAVED_PLACE_MOCKS.map((place) => ({ + id: place.id, + name: place.name, + address: place.address, + category: place.category, + tagKeys: place.tagKeys, + memo: MEMO_BY_PLACE_ID[place.id], +})); export const savedCourses: SavedCourse[] = [ { id: "course-1", - title: "서울 동대문구 0408", + title: "외대앞 점심 코스", executedAtLabel: "2일 전 실행한 코스", badgeLabel: "친구", stops: [ { - id: "stop-1", - name: "한국외국어대학교 서울캠퍼스", + id: "restaurant-1", + name: "영화장", address: "서울 동대문구 이문로 107", - walkingTime: "도보 10분", + walkingTime: "도보 4분", + hours: "11:30 ~ 21:00", }, { - id: "stop-2", - name: "감동", - address: "회기로 25길 101-13 1층", - walkingTime: "도보 10분", + id: "cafe-1", + name: "카페양귀비", + address: "서울 동대문구 이문로 85", + walkingTime: "도보 3분", + hours: "12:00 ~ 21:00", }, { - id: "stop-3", - name: "샤로스톤 외대점", - address: "회기로 27", + id: "activity-1", + name: "경희대학교 캠퍼스", + address: "서울 동대문구 경희대로 26", + walkingTime: "도보 12분", + hours: "상시 개방", }, ], }, { id: "course-2", - title: "망원동 벚꽃데이트 코스", + title: "회기역 저녁 코스", executedAtLabel: "7일 전 실행한 코스", badgeLabel: "하트", stops: [ { - id: "stop-4", - name: "망원시장", - address: "서울 마포구 포은로8길 14", - walkingTime: "도보 8분", + id: "restaurant-3", + name: "79번지국수집", + address: "서울 동대문구 이문로 79", + walkingTime: "도보 6분", + hours: "10:00 ~ 21:00", }, { - id: "stop-5", - name: "망원한강공원", - address: "서울 마포구 마포나루길 467", + id: "cafe-3", + name: "커피힐", + address: "서울 동대문구 회기로 165", + walkingTime: "도보 5분", + hours: "10:00 ~ 22:00", + }, + { + id: "activity-2", + name: "홍릉수목원", + address: "서울 동대문구 회기로 57", + walkingTime: "도보 8분", + hours: "09:00 ~ 18:00", }, ], }, { id: "course-3", - title: "간만에 휴가 데이트", + title: "이문동 가벼운 데이트", executedAtLabel: "03.08 실행한 코스", badgeLabel: "하트", stops: [ { - id: "stop-6", - name: "연남동 산책길", - address: "서울 마포구 연남동", - walkingTime: "도보 12분", + id: "restaurant-2", + name: "카츠이로하", + address: "서울 동대문구 회기로 173", + walkingTime: "도보 5분", + hours: "11:30 ~ 21:00", }, { - id: "stop-7", - name: "작은 카페", - address: "서울 마포구 동교로 41길", + id: "cafe-2", + name: "컴투레스트", + address: "서울 동대문구 회기로 171", + walkingTime: "도보 4분", + hours: "11:00 ~ 22:00", + }, + { + id: "activity-3", + name: "회기 파전골목", + address: "서울 동대문구 회기로 190 일대", + walkingTime: "도보 3분", + hours: "17:00 ~ 자정 전후", }, ], }, - { - id: "course-4", - title: "간만에 휴가 데이트", - executedAtLabel: "03.08 실행한 코스", - badgeLabel: "하트", - stops: [{ id: "stop-8", name: "성수 카페거리", address: "서울 성동구 성수동" }], - }, - { - id: "course-5", - title: "간만에 휴가 데이트", - executedAtLabel: "03.08 실행한 코스", - badgeLabel: "하트", - stops: [{ id: "stop-9", name: "서울숲", address: "서울 성동구 뚝섬로 273" }], - }, - { - id: "course-6", - title: "간만에 휴가 데이트", - executedAtLabel: "03.08 실행한 코스", - badgeLabel: "하트", - stops: [{ id: "stop-10", name: "뚝섬 한강공원", address: "서울 광진구 강변북로 139" }], - }, - { - id: "course-7", - title: "간만에 휴가 데이트", - executedAtLabel: "03.08 실행한 코스", - badgeLabel: "하트", - stops: [{ id: "stop-11", name: "압구정 로데오", address: "서울 강남구 압구정로" }], - }, ]; diff --git a/src/components/mypage/saved-course-planner-map.ts b/src/components/mypage/saved-course-planner-map.ts new file mode 100644 index 0000000..b4e36dc --- /dev/null +++ b/src/components/mypage/saved-course-planner-map.ts @@ -0,0 +1,54 @@ +import type { CourseStop as PlannerCourseStop } from "@/components/course-planner/CoursePlaceInfoPanel"; +import type { SavedCourse, SavedPlace } from "@/components/mypage/mypage-mock-data"; +import { SAVED_PLACE_MOCKS } from "@/pages/map/map-home-mock"; +import type { SavedPlace as MapSavedPlace } from "@/shared/types/map-home"; + +function resolvePlaceId( + stop: { id: string; name: string; address: string }, + savedPlaces: SavedPlace[], +) { + const fromMy = savedPlaces.find((p) => p.name === stop.name || p.address === stop.address); + if (fromMy) return fromMy.id; + + const fromMap = SAVED_PLACE_MOCKS.find((p) => p.name === stop.name || p.address === stop.address); + if (fromMap) return fromMap.id; + + return stop.id; +} + +export function savedCourseToPlannerStops( + course: SavedCourse, + savedPlaces: SavedPlace[], +): PlannerCourseStop[] { + return course.stops.map((stop) => ({ + id: stop.id, + placeId: resolvePlaceId(stop, savedPlaces), + name: stop.name, + address: stop.address, + category: "저장 코스", + walkingTime: stop.walkingTime?.trim() || "-", + hours: stop.hours?.trim() || "-", + })); +} + +/** 저장 코스에 포함된 정류의 지도 좌표(목 목데이터) — 핀 표시용 */ +export function mapPlacesFromSavedCourses( + courses: SavedCourse[], + savedPlaces: SavedPlace[], +): MapSavedPlace[] { + const seen = new Set(); + const result: MapSavedPlace[] = []; + + for (const course of courses) { + for (const stop of savedCourseToPlannerStops(course, savedPlaces)) { + if (seen.has(stop.placeId)) continue; + const mock = SAVED_PLACE_MOCKS.find((p) => p.id === stop.placeId); + if (mock) { + seen.add(stop.placeId); + result.push(mock); + } + } + } + + return result; +} diff --git a/src/components/room/EditRoomNameModal.tsx b/src/components/room/EditRoomNameModal.tsx index dce0a72..cb946e2 100644 --- a/src/components/room/EditRoomNameModal.tsx +++ b/src/components/room/EditRoomNameModal.tsx @@ -109,7 +109,7 @@ const EditRoomNameModalInner = memo(function EditRoomNameModalInner({ } className={cn( "border-input placeholder:text-muted-foreground bg-background h-11 w-full rounded-xl border px-4 text-sm outline-none", - "focus-visible:ring-ring focus-visible:ring-2", + "ring-0 focus:ring-0 focus-visible:ring-0", errorMessage ? "border-destructive" : undefined, )} placeholder="방 이름을 입력해 주세요" @@ -145,7 +145,7 @@ const EditRoomNameModalInner = memo(function EditRoomNameModalInner({ +
+ + + +

+ {title} +

diff --git a/src/components/room/RoomMainShell.tsx b/src/components/room/RoomMainShell.tsx index e041280..9148ec7 100644 --- a/src/components/room/RoomMainShell.tsx +++ b/src/components/room/RoomMainShell.tsx @@ -12,18 +12,23 @@ export type RoomMainShellProps = { }; /** - * 방 메인 계열 화면 공통 셸: 엣지 투 엣지, 스크롤 본문, 하단 고정 네비 + FAB 위치. + * 방 메인 계열 화면 공통 셸: 엣지 투 엣지, 스크롤 본문, 하단 네비·FAB는 지도처럼 절대 위치로 겹침. */ export function RoomMainShell({ header, children, bottomNav, fab, className }: RoomMainShellProps) { return ( -
+
{header}
{children}
-
+
{fab != null ? (
{fab}
) : null} diff --git a/src/components/ui/BottomSheet.tsx b/src/components/ui/BottomSheet.tsx index fc153d3..0c67e04 100644 --- a/src/components/ui/BottomSheet.tsx +++ b/src/components/ui/BottomSheet.tsx @@ -6,6 +6,9 @@ import { cn } from "@/lib/utils"; const BOTTOM_SHEET_TRANSITION_MS = 240; const DRAG_CLOSE_THRESHOLD = 96; +/** 패널(드래그 핸들 포함) 최대 높이 — 모든 BottomSheet 기본값 (상단 여백 최소) */ +const BOTTOM_SHEET_PANEL_MAX_HEIGHT_CLASS = "max-h-[calc(100dvh-0.5rem)]"; + export type BottomSheetProps = { open: boolean; onClose: () => void; @@ -16,6 +19,11 @@ export type BottomSheetProps = { contentClassName?: string; hideHandle?: boolean; enableHistory?: boolean; + /** + * true면 시트 패널 높이가 콘텐츠만큼 올라가고(max까지), 넘치는 부분만 본문에서 스크롤한다. + * false면 본문이 남은 영역을 채우듯 늘어나며(레거시) 스크롤한다. + */ + intrinsicPanelHeight?: boolean; }; export function BottomSheet({ @@ -28,6 +36,7 @@ export function BottomSheet({ contentClassName, hideHandle = false, enableHistory = true, + intrinsicPanelHeight = false, }: BottomSheetProps) { const { isRendered, isVisible, requestClose } = useOverlayFlowController({ open, @@ -121,7 +130,9 @@ export function BottomSheet({
diff --git a/src/features/map/hooks/use-map-search-filters.ts b/src/features/map/hooks/use-map-search-filters.ts index 206bb3f..744a68c 100644 --- a/src/features/map/hooks/use-map-search-filters.ts +++ b/src/features/map/hooks/use-map-search-filters.ts @@ -15,6 +15,11 @@ type UseMapSearchFiltersOptions = { places: SavedPlace[]; filterCategories: Category[]; initialFocusedCategory?: MapPrimaryCategory | null; + /** + * true면 태그 UI 없이 카테고리 칩만 사용한다. + * 선택한 주 카테고리로만 필터하며(place.category), 세부 태그는 무시한다. + */ + categoriesOnly?: boolean; }; type UseMapSearchFiltersResult = { @@ -64,6 +69,7 @@ export function useMapSearchFilters({ places, filterCategories, initialFocusedCategory = null, + categoriesOnly = false, }: UseMapSearchFiltersOptions): UseMapSearchFiltersResult { const [keyword, setKeyword] = useState(""); const [focusedCategoryState, setFocusedCategoryState] = useState(null); @@ -95,6 +101,10 @@ export function useMapSearchFilters({ ); const focusedCategory = useMemo(() => { + if (categoriesOnly) { + return null; + } + if (focusedCategoryState && categoryCodeSet.has(focusedCategoryState)) { return focusedCategoryState; } @@ -108,7 +118,13 @@ export function useMapSearchFilters({ } return null; - }, [categoryCodeSet, focusedCategoryState, initialFocusedCategory, isInitialFocusDismissed]); + }, [ + categoriesOnly, + categoryCodeSet, + focusedCategoryState, + initialFocusedCategory, + isInitialFocusDismissed, + ]); const selectedCategories = useMemo(() => { const normalized = selectedCategoriesState.filter((category) => categoryCodeSet.has(category)); @@ -118,6 +134,7 @@ export function useMapSearchFilters({ } if ( + !categoriesOnly && !isInitialFocusDismissed && initialFocusedCategory && categoryCodeSet.has(initialFocusedCategory) @@ -126,7 +143,13 @@ export function useMapSearchFilters({ } return normalized; - }, [categoryCodeSet, initialFocusedCategory, isInitialFocusDismissed, selectedCategoriesState]); + }, [ + categoriesOnly, + categoryCodeSet, + initialFocusedCategory, + isInitialFocusDismissed, + selectedCategoriesState, + ]); const selectedTagKeysByCategory = useMemo( () => normalizeSelectedTagKeysByCategory(selectedTagKeysByCategoryState, primaryCategories), @@ -148,6 +171,19 @@ export function useMapSearchFilters({ return; } + if (categoriesOnly) { + setFocusedCategoryState(null); + setSelectedCategoriesState((previous) => { + const normalized = previous.filter((current) => categoryCodeSet.has(current)); + const isSelected = normalized.includes(category); + if (!isSelected) { + return [...normalized, category]; + } + return normalized.filter((current) => current !== category); + }); + return; + } + setSelectedCategoriesState((previous) => { const normalized = previous.filter((current) => categoryCodeSet.has(current)); const isSelected = normalized.includes(category); @@ -166,7 +202,7 @@ export function useMapSearchFilters({ return normalized; }); }, - [categoryCodeSet, focusedCategory, primaryCategories], + [categoriesOnly, categoryCodeSet, focusedCategory, primaryCategories], ); const closeTagPanel = useCallback(() => { @@ -232,13 +268,15 @@ export function useMapSearchFilters({ [selectedTagCountByCategory], ); - // 카테고리 chip active 조건: “상세 태그가 1개 이상 선택된 카테고리” + // 카테고리 chip active: 기본은 “해당 카테고리에 태그 1개 이상”; categoriesOnly는 선택한 주 카테고리만 const activeCategories = useMemo( () => - selectedCategories.filter( - (category) => (selectedTagKeysByCategory[category] ?? []).length > 0, - ), - [selectedCategories, selectedTagKeysByCategory], + categoriesOnly + ? selectedCategories + : selectedCategories.filter( + (category) => (selectedTagKeysByCategory[category] ?? []).length > 0, + ), + [categoriesOnly, selectedCategories, selectedTagKeysByCategory], ); const selectedCategorySet = useMemo(() => new Set(activeCategories), [activeCategories]); @@ -258,19 +296,23 @@ export function useMapSearchFilters({ return false; } - const categoryTagKeys = selectedTagKeysByCategory[placeCategoryCode] ?? []; - if (categoryTagKeys.length > 0) { - const allTagKey = allTagKeyByCategory[placeCategoryCode]; - const hasAllTagSelected = Boolean(allTagKey && categoryTagKeys.includes(allTagKey)); - - if (!hasAllTagSelected) { - if (!place.tagKeys || place.tagKeys.length === 0) { - return false; - } - - const hasMatchedTag = categoryTagKeys.some((tagKey) => place.tagKeys?.includes(tagKey)); - if (!hasMatchedTag) { - return false; + if (!categoriesOnly) { + const categoryTagKeys = selectedTagKeysByCategory[placeCategoryCode] ?? []; + if (categoryTagKeys.length > 0) { + const allTagKey = allTagKeyByCategory[placeCategoryCode]; + const hasAllTagSelected = Boolean(allTagKey && categoryTagKeys.includes(allTagKey)); + + if (!hasAllTagSelected) { + if (!place.tagKeys || place.tagKeys.length === 0) { + return false; + } + + const hasMatchedTag = categoryTagKeys.some((tagKey) => + place.tagKeys?.includes(tagKey), + ); + if (!hasMatchedTag) { + return false; + } } } } @@ -286,6 +328,7 @@ export function useMapSearchFilters({ }, [ activeCategories.length, allTagKeyByCategory, + categoriesOnly, categoryCodeSet, keyword, places, diff --git a/src/features/map/lib/fallback-place-filter-data.ts b/src/features/map/lib/fallback-place-filter-data.ts new file mode 100644 index 0000000..de48ece --- /dev/null +++ b/src/features/map/lib/fallback-place-filter-data.ts @@ -0,0 +1,68 @@ +import type { PlaceFilterData } from "@/features/map/api/place-taxonomy-types"; + +/** + * 로그인 미만·API 미응답 시 사용하는 폴백. + * `SAVED_PLACE_MOCKS`의 카테고리/태그 코드와 맞춰 둔다. + */ +export const FALLBACK_PLACE_FILTER_DATA: PlaceFilterData = { + categories: [ + { + code: "맛집", + name: "맛집", + sortOrder: 1, + tagGroups: [ + { + code: "맛집-default", + name: null, + sortOrder: 1, + tags: [ + { code: "ALL", name: "전체", sortOrder: 0 }, + { code: "맛집-한식", name: "한식", sortOrder: 1 }, + { code: "맛집-중식", name: "중식", sortOrder: 2 }, + { code: "맛집-일식", name: "일식", sortOrder: 3 }, + ], + }, + ], + }, + { + code: "카페", + name: "카페", + sortOrder: 2, + tagGroups: [ + { + code: "카페-default", + name: null, + sortOrder: 1, + tags: [ + { code: "ALL", name: "전체", sortOrder: 0 }, + { code: "카페-커피", name: "커피", sortOrder: 1 }, + { code: "카페-베이커리", name: "베이커리", sortOrder: 2 }, + { code: "카페-디저트", name: "디저트", sortOrder: 3 }, + ], + }, + ], + }, + { + code: "놀거리", + name: "놀거리", + sortOrder: 3, + tagGroups: [ + { + code: "놀거리-default", + name: null, + sortOrder: 1, + tags: [{ code: "ALL", name: "전체", sortOrder: 0 }], + }, + { + code: "놀거리-type", + name: "놀거리 종류", + sortOrder: 2, + tags: [ + { code: "놀거리-산책", name: "산책", sortOrder: 1 }, + { code: "놀거리-먹거리", name: "먹거리", sortOrder: 2 }, + ], + }, + ], + }, + ], +}; diff --git a/src/features/room/link-add/mock-link-processing.ts b/src/features/room/link-add/mock-link-processing.ts index b7fe13f..bafa1c7 100644 --- a/src/features/room/link-add/mock-link-processing.ts +++ b/src/features/room/link-add/mock-link-processing.ts @@ -1,32 +1,29 @@ -import type { MockPlaceCandidate } from "@/features/room/link-add/types"; +import type { MockPlaceCandidate } from "@/features/room/link-add/types"; +import { SAVED_PLACE_MOCKS } from "@/pages/map/map-home-mock"; export function buildMockPlacesFromCaption(caption: string | null): MockPlaceCandidate[] { - if (!caption || caption.trim().length === 0) { + const captionTokens = + caption + ?.toLowerCase() + .split(/\s+/) + .map((token) => token.trim()) + .filter((token) => token.length > 1) ?? []; + + if (captionTokens.length === 0) { return getMockPlaces(); } - const seeds = caption - .split(" ") - .map((word) => word.trim()) - .filter((word) => word.length > 1) - .slice(0, 3); + const matchedPlaces = SAVED_PLACE_MOCKS.filter((place) => + captionTokens.some((token) => + `${place.name} ${place.address} ${place.category}`.toLowerCase().includes(token), + ), + ); - if (seeds.length === 0) { - return getMockPlaces(); - } - - return seeds.map((seed, index) => ({ - id: `mock-place-${index + 1}`, - name: `${seed} 추천 장소 ${index + 1}`, - })); + return toMockPlaceCandidates(matchedPlaces.length > 0 ? matchedPlaces : SAVED_PLACE_MOCKS); } export function getMockPlaces(): MockPlaceCandidate[] { - return [ - { id: "mock-place-1", name: "사사노하" }, - { id: "mock-place-2", name: "원할머니 보쌈" }, - { id: "mock-place-3", name: "승원" }, - ]; + return toMockPlaceCandidates(SAVED_PLACE_MOCKS); } export async function confirmMockPlaceSelection(params: { @@ -39,6 +36,15 @@ export async function confirmMockPlaceSelection(params: { }; } +function toMockPlaceCandidates( + places: Pick<(typeof SAVED_PLACE_MOCKS)[number], "id" | "name">[], +): MockPlaceCandidate[] { + return places.map((place) => ({ + id: place.id, + name: place.name, + })); +} + function delay(ms: number): Promise { return new Promise((resolve) => { window.setTimeout(resolve, ms); diff --git a/src/hooks/use-place-detail-open-event.ts b/src/hooks/use-place-detail-open-event.ts new file mode 100644 index 0000000..6bb4a3b --- /dev/null +++ b/src/hooks/use-place-detail-open-event.ts @@ -0,0 +1,25 @@ +import { useEffect } from "react"; + +import { PLACE_DETAIL_OPEN_EVENT, usePlaceDetailStore } from "@/store/placeDetailStore"; + +type PlaceDetailOpenEvent = CustomEvent<{ + placeId: string; +}>; + +/** Kakao 마커 클릭 등 전역 커스텀 이벤트 → 상세 바텀 시트 오픈 */ +export function usePlaceDetailOpenEvent(subscribed: boolean) { + const openDetail = usePlaceDetailStore((s) => s.openDetail); + + useEffect(() => { + if (!subscribed) return; + + const handleOpenDetail = (event: Event) => { + const { detail } = event as PlaceDetailOpenEvent; + if (!detail?.placeId) return; + openDetail(detail.placeId); + }; + + window.addEventListener(PLACE_DETAIL_OPEN_EVENT, handleOpenDetail); + return () => window.removeEventListener(PLACE_DETAIL_OPEN_EVENT, handleOpenDetail); + }, [openDetail, subscribed]); +} diff --git a/src/index.css b/src/index.css index b6aaab6..25ba9bc 100644 --- a/src/index.css +++ b/src/index.css @@ -166,7 +166,7 @@ body { ); } - /** FAB를 하단 네비 바로 위에 띄울 때 (부모는 `relative shrink-0`) */ + /** FAB를 하단 네비 바로 위에 띄울 때 (부모는 하단 오버레이 `absolute bottom-0` 래퍼) */ .bottom-fab-above-nav { bottom: calc(100% + var(--room-fab-gap-above-nav)); } diff --git a/src/pages/map/MapHomePage.tsx b/src/pages/map/MapHomePage.tsx index 35c3f98..561c9c1 100644 --- a/src/pages/map/MapHomePage.tsx +++ b/src/pages/map/MapHomePage.tsx @@ -126,7 +126,7 @@ export function MapHomePageContent({
-
+
= { + "course-1": [ + { + id: "course-1-stop-1", + placeId: "restaurant-1", + name: "영화장", + address: "서울 동대문구 이문로 107", + category: "맛집", + walkingTime: "도보 4분", + hours: "11:30 ~ 21:00", + }, + { + id: "course-1-stop-2", + placeId: "cafe-1", + name: "카페양귀비", + address: "서울 동대문구 이문로 85", + category: "카페", + walkingTime: "도보 3분", + hours: "12:00 ~ 21:00", + }, + { + id: "course-1-stop-3", + placeId: "activity-1", + name: "경희대학교 캠퍼스", + address: "서울 동대문구 경희대로 26", + category: "놀거리", + walkingTime: "도보 12분", + hours: "상시 개방", + }, + ], + "course-2": [ + { + id: "course-2-stop-1", + placeId: "restaurant-3", + name: "79번지국수집", + address: "서울 동대문구 이문로 79", + category: "맛집", + walkingTime: "도보 6분", + hours: "10:00 ~ 21:00", + }, + { + id: "course-2-stop-2", + placeId: "cafe-3", + name: "커피힐", + address: "서울 동대문구 회기로 165", + category: "카페", + walkingTime: "도보 5분", + hours: "10:00 ~ 22:00", + }, + { + id: "course-2-stop-3", + placeId: "activity-2", + name: "홍릉수목원", + address: "서울 동대문구 회기로 57", + category: "놀거리", + walkingTime: "도보 8분", + hours: "09:00 ~ 18:00", + }, + ], + "course-3": [ + { + id: "course-3-stop-1", + placeId: "restaurant-2", + name: "카츠이로하", + address: "서울 동대문구 회기로 173", + category: "맛집", + walkingTime: "도보 5분", + hours: "11:30 ~ 21:00", + }, + { + id: "course-3-stop-2", + placeId: "cafe-2", + name: "컴투레스트", + address: "서울 동대문구 회기로 171", + category: "카페", + walkingTime: "도보 4분", + hours: "11:00 ~ 22:00", + }, + { + id: "course-3-stop-3", + placeId: "activity-3", + name: "회기 파전골목", + address: "서울 동대문구 회기로 190 일대", + category: "놀거리", + walkingTime: "도보 3분", + hours: "17:00 ~ 자정 전후", + }, + ], +}; type CoursePlannerPageProps = { skipRoomGuard?: boolean; }; +function getMockCourseStops(courseId: string): CourseStop[] { + return mockStopsByCourseId[courseId] ?? mockStopsByCourseId["course-1"]; +} + function CourseDevMapBackground({ onSelectBottomNav, }: { @@ -101,7 +166,7 @@ function CourseDevMapBackground({ -
+
@@ -116,22 +181,47 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann const [mode, setMode] = useState("form"); const [regionValue, setRegionValue] = useState(""); const [draftCity, setDraftCity] = useState("서울"); - const [draftDistrict, setDraftDistrict] = useState("강남구"); + const [draftDistrict, setDraftDistrict] = useState("동대문구"); const [dateTimeValue, setDateTimeValue] = useState(null); const [draftDate, setDraftDate] = useState(null); const [draftStartTime, setDraftStartTime] = useState(null); const [draftEndTime, setDraftEndTime] = useState(null); const [selectedCourseId, setSelectedCourseId] = useState(""); const [courseTitle, setCourseTitle] = useState(mockCourses[0]?.title ?? "코스 1"); - const [courseStops, setCourseStops] = useState(mockStops); + const [courseStops, setCourseStops] = useState(() => + getMockCourseStops("course-1"), + ); const { - categories, - categoryNameByCode, - filterCategories, + filterCategories: apiFilterCategories, isInitialLoading, isInitialError, retryLoad, } = usePlaceFilterData(); + + const filterCategories = useMemo(() => { + if (apiFilterCategories.length > 0) return apiFilterCategories; + if (isInitialLoading && !isInitialError) return []; + return FALLBACK_PLACE_FILTER_DATA.categories; + }, [apiFilterCategories, isInitialLoading, isInitialError]); + + const categories = useMemo( + () => [MAP_ALL_CATEGORY_FILTER_CHIP, ...filterCategories.map((category) => category.code)], + [filterCategories], + ); + + const categoryNameByCode = useMemo( + () => + filterCategories.reduce( + (accumulator, category) => { + accumulator[category.code as MapPrimaryCategory] = category.name; + return accumulator; + }, + {} as Record, + ), + [filterCategories], + ); + + const isCategoryUiLoading = filterCategories.length === 0 && isInitialLoading && !isInitialError; const { activeCategories, focusedCategory, @@ -145,13 +235,14 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann } = useMapSearchFilters({ places: SAVED_PLACE_MOCKS, filterCategories, + initialFocusedCategory: "놀거리", }); useEffect(() => { if (mode !== "loading") return; const timerId = window.setTimeout(() => { - showToast("데이트코스가 완성되었습니다", 3200); + showToast("데이트 코스가 완성되었습니다", 3200); setSelectedCourseId(""); setMode("result"); }, 900); @@ -222,7 +313,7 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann const handleResetPlanner = () => { setRegionValue(""); setDraftCity("서울"); - setDraftDistrict("강남구"); + setDraftDistrict("동대문구"); setDateTimeValue(null); setDraftDate(null); setDraftStartTime(null); @@ -230,7 +321,7 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann toggleCategory(MAP_ALL_CATEGORY_FILTER_CHIP); setSelectedCourseId(""); setCourseTitle(mockCourses[0]?.title ?? "코스 1"); - setCourseStops(mockStops); + setCourseStops(getMockCourseStops("course-1")); setMode("form"); }; @@ -243,7 +334,7 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann const selectedCourse = mockCourses.find((course) => course.id === courseId); setSelectedCourseId(courseId); setCourseTitle(selectedCourse?.title ?? "코스 1"); - setCourseStops(mockStops); + setCourseStops(getMockCourseStops(courseId)); setMode("detail"); }; @@ -261,8 +352,8 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann categories, categoryNameByCode, filterCategories, - isCategoryLoading: isInitialLoading, - isCategoryError: isInitialError, + isCategoryLoading: isCategoryUiLoading, + isCategoryError: Boolean(isInitialError && apiFilterCategories.length === 0), onRetryLoadCategories: () => { void retryLoad(); }, diff --git a/src/pages/tabs/MyPage.tsx b/src/pages/tabs/MyPage.tsx index 0dc7ef4..fa9e886 100644 --- a/src/pages/tabs/MyPage.tsx +++ b/src/pages/tabs/MyPage.tsx @@ -2,68 +2,99 @@ import { useState } from "react"; import { BottomNavigationBar } from "@/components/common/BottomNavigationBar"; import { BottomNavToast } from "@/components/common/BottomNavToast"; +import { type CourseStop as PlannerCourseStop } from "@/components/course-planner/CoursePlaceInfoPanel"; import { MyAccountActions } from "@/components/mypage/MyAccountActions"; import { - myPageUser, type SavedCourse, - savedCourses, + savedCourses as seedSavedCourses, type SavedPlace, savedPlaces as initialSavedPlaces, } from "@/components/mypage/mypage-mock-data"; -import { MyPageCourseDetail } from "@/components/mypage/MyPageCourseDetail"; import { MyPlaceSummaryCard } from "@/components/mypage/MyPlaceSummaryCard"; import { MyProfileHeader } from "@/components/mypage/MyProfileHeader"; +import { MySavedCoursesPage } from "@/components/mypage/MySavedCoursesPage"; import { MySavedPlacesPage } from "@/components/mypage/MySavedPlacesPage"; -import { SavedCourseListPage } from "@/components/mypage/SavedCourseListPage"; import { SavedCourseSection } from "@/components/mypage/SavedCourseSection"; -import { SavedPlaceDetailPage } from "@/components/mypage/SavedPlaceDetailPage"; +import { PlaceDetailSheet } from "@/components/place/PlaceDetailSheet"; +import { useLogout } from "@/features/auth/hooks/use-logout"; +import { useUserMeQuery } from "@/features/users"; import { useBottomNavController } from "@/hooks/use-bottom-nav-controller"; +import { usePlaceDetailOpenEvent } from "@/hooks/use-place-detail-open-event"; +import { useAuthStore } from "@/store/auth-store"; +import { usePlaceDetailStore } from "@/store/placeDetailStore"; type MyPageView = "main" | "places" | "courses"; +type SavedCourseSheetState = { kind: "closed" } | { kind: "detail"; course: SavedCourse }; + export default function MyPage() { - const { toastMessage, toastPlacement, handleSelectBottomNav } = useBottomNavController(); + const { toastMessage, toastPlacement, handleSelectBottomNav, showToast } = + useBottomNavController(); + const { handleLogout } = useLogout(); + const nicknameFromAuth = useAuthStore((s) => s.nickname); + const { data: me } = useUserMeQuery(); + const profileNickname = + me?.nickname?.trim() ?? (nicknameFromAuth?.trim().length ? nicknameFromAuth.trim() : ""); + const displayNickname = profileNickname.length > 0 ? profileNickname : "회원"; + const profileImageUrl = me?.profileImageUrl ?? null; + const [view, setView] = useState("main"); - const [visibleCourseCount, setVisibleCourseCount] = useState(5); - const [selectedCourse, setSelectedCourse] = useState(null); - const [selectedPlace, setSelectedPlace] = useState(null); + const [savedCourseSheet, setSavedCourseSheet] = useState({ + kind: "closed", + }); + const [coursesList, setCoursesList] = useState(() => [...seedSavedCourses]); const [places, setPlaces] = useState(initialSavedPlaces); - const handleShowMoreCourses = () => { - setVisibleCourseCount((count) => Math.min(count + 5, savedCourses.length)); - }; + const openPlaceDetail = usePlaceDetailStore((s) => s.openDetail); + const closePlaceDetail = usePlaceDetailStore((s) => s.closeDetail); - if (selectedCourse) { - return ( -
- setSelectedCourse(null)} /> - - -
- ); - } + usePlaceDetailOpenEvent(view === "places" || view === "courses"); - if (selectedPlace) { - return ( -
- setSelectedPlace(null)} /> - - -
- ); - } + const handleSavedCoursePersist = ( + prevCourseId: string, + nextTitle: string, + nextStops: PlannerCourseStop[], + fromEditMode: boolean, + ) => { + showToast("코스가 저장되었습니다", 3200); + + const nextStopsMinimal = nextStops.map((s) => ({ + id: s.id, + name: s.name, + address: s.address, + walkingTime: s.walkingTime === "—" ? undefined : s.walkingTime, + hours: s.hours === "—" ? undefined : s.hours, + })); + + if (fromEditMode) { + const updated: SavedCourse | undefined = coursesList.find((c) => c.id === prevCourseId); + if (!updated) return; + + const merged: SavedCourse = { ...updated, title: nextTitle, stops: nextStopsMinimal }; + setCoursesList((list) => list.map((c) => (c.id === prevCourseId ? merged : c))); + setSavedCourseSheet({ kind: "detail", course: merged }); + } else { + setSavedCourseSheet({ kind: "closed" }); + } + }; if (view === "places") { return (
setView("main")} + onBack={() => { + closePlaceDetail(); + setView("main"); + }} onChangePlaces={setPlaces} - onSelectPlace={setSelectedPlace} + onSelectPlace={(place) => openPlaceDetail(place.id)} /> - - +
+ + +
+
); } @@ -71,43 +102,65 @@ export default function MyPage() { if (view === "courses") { return (
- setView("main")} - onSelectCourse={setSelectedCourse} + setSavedCourseSheet({ kind: "detail", course })} + onCloseCourseSheet={() => setSavedCourseSheet({ kind: "closed" })} + onBack={() => { + closePlaceDetail(); + setSavedCourseSheet({ kind: "closed" }); + setView("main"); + }} + onPersistCourse={handleSavedCoursePersist} /> - - +
+ + +
+
); } return (
-
- +
+
({ id: place.id, name: place.name }))} - onOpenPlaces={() => setView("places")} + onOpenPlaces={() => { + closePlaceDetail(); + setView("places"); + }} /> setView("courses")} - onSelectCourse={setSelectedCourse} + courses={coursesList} + onOpenFullList={() => { + setSavedCourseSheet({ kind: "closed" }); + setView("courses"); + }} + onSelectCourse={(course) => { + setSavedCourseSheet({ kind: "detail", course }); + setView("courses"); + }} /> - + void handleLogout()} />
- - +
+ + +
+ +
); } diff --git a/src/pages/tabs/PlaceListPage.tsx b/src/pages/tabs/PlaceListPage.tsx index 1a7049a..abf1939 100644 --- a/src/pages/tabs/PlaceListPage.tsx +++ b/src/pages/tabs/PlaceListPage.tsx @@ -15,9 +15,12 @@ export default function PlaceListPage() { return (
-
- - +
+ +
+ + +
); }