diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 029386f..ca69e43 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: "dev/course", element: }, { path: "app", element: }, 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 new file mode 100644 index 0000000..ef6ff94 --- /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/MyPlaceSummaryCard.tsx b/src/components/mypage/MyPlaceSummaryCard.tsx new file mode 100644 index 0000000..6882e7b --- /dev/null +++ b/src/components/mypage/MyPlaceSummaryCard.tsx @@ -0,0 +1,62 @@ +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..d8d578f --- /dev/null +++ b/src/components/mypage/MyProfileHeader.tsx @@ -0,0 +1,47 @@ +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, 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 ( +
+ {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 new file mode 100644 index 0000000..1946944 --- /dev/null +++ b/src/components/mypage/MySavedPlacesPage.tsx @@ -0,0 +1,304 @@ +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 { 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; + 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 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 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); + + 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 ? "나의 장소를 저장해보세요!" : "해당하는 장소가 없습니다."; + + 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(""); + } + }; + + 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} + +
+
+ +

+ 나의 장소 +

+ + {displayedCountLabel} + +
+ + {!detailOpen ? ( +
+ { + void retryLoad(); + }} + activeCategories={activeCategories} + focusedCategory={focusedCategory} + onToggleCategory={toggleCategory} + isTagPanelOpen={isTagPanelOpen} + selectedTagKeysByCategory={selectedTagKeysByCategory} + selectedTagCountByCategory={selectedTagCountByCategory} + onToggleTagInCategory={toggleTagInCategory} + onResetFocusedCategoryTags={resetFocusedCategoryTags} + onCloseTagPanel={closeTagPanel} + /> +
+ ) : null} +
+ + {!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/SavedCourseCard.tsx b/src/components/mypage/SavedCourseCard.tsx new file mode 100644 index 0000000..61af1c5 --- /dev/null +++ b/src/components/mypage/SavedCourseCard.tsx @@ -0,0 +1,47 @@ +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/SavedCourseSection.tsx b/src/components/mypage/SavedCourseSection.tsx new file mode 100644 index 0000000..a7ea102 --- /dev/null +++ b/src/components/mypage/SavedCourseSection.tsx @@ -0,0 +1,55 @@ +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[]; + onOpenFullList: () => void; + onSelectCourse: (course: SavedCourse) => void; +}; + +export function SavedCourseSection({ + courses, + onOpenFullList, + onSelectCourse, +}: SavedCourseSectionProps) { + const previewCourses = courses.slice(0, PREVIEW_COUNT); + const hasCourses = courses.length > 0; + + return ( +
+
+

+ 저장된 데이트 코스 +

+ {hasCourses ? ( + + ) : null} +
+ + {hasCourses ? ( +
+ {previewCourses.map((course) => ( + + ))} +
+ ) : ( +
+ 데이트 코스를 저장해보세요! +
+ )} +
+ ); +} diff --git a/src/components/mypage/SavedPlaceItem.tsx b/src/components/mypage/SavedPlaceItem.tsx new file mode 100644 index 0000000..796133c --- /dev/null +++ b/src/components/mypage/SavedPlaceItem.tsx @@ -0,0 +1,97 @@ +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"; + +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) { + 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} + + {isEditing ? ( + + ) : null} +
+ ); +} diff --git a/src/components/mypage/SavedPlaceMemoEditor.tsx b/src/components/mypage/SavedPlaceMemoEditor.tsx new file mode 100644 index 0000000..8e5e702 --- /dev/null +++ b/src/components/mypage/SavedPlaceMemoEditor.tsx @@ -0,0 +1,73 @@ +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +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="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 new file mode 100644 index 0000000..fb3a19e --- /dev/null +++ b/src/components/mypage/mypage-mock-data.ts @@ -0,0 +1,140 @@ +import { SAVED_PLACE_MOCKS } from "@/pages/map/map-home-mock"; +import type { MapPrimaryCategory } from "@/shared/types/map-home"; + +export type RecentPlace = { + id: string; + name: string; +}; + +export type SavedPlace = { + id: string; + name: string; + address: string; + /** 지도·필터와 동일한 1차 카테고리 코드 */ + category: MapPrimaryCategory; + /** `SAVED_PLACE_MOCKS`와 같은 태그 코드 */ + tagKeys?: string[]; + memo?: string; +}; + +export type SavedCourse = { + id: string; + title: string; + executedAtLabel: string; + badgeLabel: string; + stops: CourseStop[]; + /** 이 코스를 저장했을 때의 방 ID(API). 없으면 방 필터는 목록 데이터가 생길 때까지 전체 표시만 적용 */ + savedFromRoomId?: string | null; +}; + +export type CourseStop = { + id: string; + name: string; + address: string; + walkingTime?: string; + hours?: string; +}; + +const MEMO_BY_PLACE_ID: Partial> = { + "cafe-2": "빵 나오는 시간 맞춰 가기", + "activity-2": "주말에는 예약 확인", +}; + +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: "외대앞 점심 코스", + executedAtLabel: "2일 전 실행한 코스", + badgeLabel: "친구", + stops: [ + { + id: "restaurant-1", + name: "영화장", + address: "서울 동대문구 이문로 107", + walkingTime: "도보 4분", + hours: "11:30 ~ 21:00", + }, + { + id: "cafe-1", + name: "카페양귀비", + address: "서울 동대문구 이문로 85", + walkingTime: "도보 3분", + hours: "12:00 ~ 21:00", + }, + { + id: "activity-1", + name: "경희대학교 캠퍼스", + address: "서울 동대문구 경희대로 26", + walkingTime: "도보 12분", + hours: "상시 개방", + }, + ], + }, + { + id: "course-2", + title: "회기역 저녁 코스", + executedAtLabel: "7일 전 실행한 코스", + badgeLabel: "하트", + stops: [ + { + id: "restaurant-3", + name: "79번지국수집", + address: "서울 동대문구 이문로 79", + walkingTime: "도보 6분", + hours: "10:00 ~ 21:00", + }, + { + 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: "이문동 가벼운 데이트", + executedAtLabel: "03.08 실행한 코스", + badgeLabel: "하트", + stops: [ + { + id: "restaurant-2", + name: "카츠이로하", + address: "서울 동대문구 회기로 173", + walkingTime: "도보 5분", + hours: "11:30 ~ 21:00", + }, + { + id: "cafe-2", + name: "컴투레스트", + address: "서울 동대문구 회기로 171", + walkingTime: "도보 4분", + hours: "11:00 ~ 22:00", + }, + { + id: "activity-3", + name: "회기 파전골목", + address: "서울 동대문구 회기로 190 일대", + walkingTime: "도보 3분", + hours: "17:00 ~ 자정 전후", + }, + ], + }, +]; 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 9464e85..fa9e886 100644 --- a/src/pages/tabs/MyPage.tsx +++ b/src/pages/tabs/MyPage.tsx @@ -1,15 +1,166 @@ +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 { + type SavedCourse, + savedCourses as seedSavedCourses, + type SavedPlace, + savedPlaces as initialSavedPlaces, +} from "@/components/mypage/mypage-mock-data"; +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 { SavedCourseSection } from "@/components/mypage/SavedCourseSection"; +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 [savedCourseSheet, setSavedCourseSheet] = useState({ + kind: "closed", + }); + const [coursesList, setCoursesList] = useState(() => [...seedSavedCourses]); + const [places, setPlaces] = useState(initialSavedPlaces); + + const openPlaceDetail = usePlaceDetailStore((s) => s.openDetail); + const closePlaceDetail = usePlaceDetailStore((s) => s.closeDetail); + + usePlaceDetailOpenEvent(view === "places" || view === "courses"); + + 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 ( +
+ { + closePlaceDetail(); + setView("main"); + }} + onChangePlaces={setPlaces} + onSelectPlace={(place) => openPlaceDetail(place.id)} + /> +
+ + +
+ +
+ ); + } + + if (view === "courses") { + return ( +
+ 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={() => { + closePlaceDetail(); + setView("places"); + }} + /> + + { + 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 (
-
- - +
+ +
+ + +
); }