From d5000677ef4fae781043b910ca80cba8ad8170c7 Mon Sep 17 00:00:00 2001 From: yuji1202 Date: Sat, 30 May 2026 21:33:12 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=AA=A9=EB=A1=9D=20=ED=83=AD=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=90=9C=20=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20UI=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록 탭에 저장된 데이트 코스 탭 UI 추가 장소 목록 / 저장된 데이트 코스 탭 전환 기능 추가 방 필터 및 날짜 필터 UI 추가 저장된 데이트 코스 리스트 및 상세 패널 연결 방 이름 옆 화살표 클릭 시 지도 이동 연결 /dev/list 확인 경로 추가 --- src/app/router/index.tsx | 8 + .../place-list/PlaceListSavedCoursesPage.tsx | 432 ++++++++++++++++++ src/pages/tabs/PlaceListPage.tsx | 63 ++- 3 files changed, 501 insertions(+), 2 deletions(-) create mode 100644 src/components/place-list/PlaceListSavedCoursesPage.tsx diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 8750823..cf9bae7 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -32,6 +32,14 @@ export const router = createBrowserRouter([ element: , children: [ { index: true, element: }, + { + path: "dev/list", + element: ( + }> + + + ), + }, { path: "places/register/from-link", element: }, { path: "places/edit", element: }, { path: "login", element: }, diff --git a/src/components/place-list/PlaceListSavedCoursesPage.tsx b/src/components/place-list/PlaceListSavedCoursesPage.tsx new file mode 100644 index 0000000..9241b5c --- /dev/null +++ b/src/components/place-list/PlaceListSavedCoursesPage.tsx @@ -0,0 +1,432 @@ +import { AlertCircle, Check, ChevronDown, ChevronRight } from "lucide-react"; +import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react"; + +import { BottomNavigationBar, type BottomNavId } from "@/components/common/BottomNavigationBar"; +import { BottomNavToast } from "@/components/common/BottomNavToast"; +import { EmptyState } from "@/components/common/EmptyState"; +import { + LIST_TOP_BAR_AFTER_TITLE_CLASS, + ListTopBar, +} from "@/components/common/ListTopBar"; +import { MapBackdropLayer } from "@/components/common/MapBackdropLayer"; +import { CoursePlaceInfoPanel } from "@/components/course-planner/CoursePlaceInfoPanel"; +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 { + mapPlacesFromSavedCourses, + savedCourseToPlannerStops, +} from "@/components/mypage/saved-course-planner-map"; +import { SavedCourseCard } from "@/components/mypage/SavedCourseCard"; +import { PlaceDetailSheet } from "@/components/place/PlaceDetailSheet"; +import { useRoomsQuery } from "@/features/room"; +import { usePointerDownOutside } from "@/hooks/use-pointer-down-outside"; +import type { BottomNavToastPlacement } from "@/hooks/use-bottom-nav-controller"; +import { cn } from "@/lib/utils"; +import { MAP_INITIAL_CENTER } from "@/shared/mocks/place-mocks"; +import type { CourseSavePayload, SavedCourse } from "@/shared/types/course"; +import type { SavedPlace } from "@/shared/types/my-page"; +import { usePlaceDetailStore } from "@/store/place-detail-store"; + +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; + +type PlaceListSavedCoursesPageProps = { + roomId?: string | null; + roomName: string; + courses: SavedCourse[]; + savedPlaces: SavedPlace[]; + toastMessage: string; + toastPlacement?: BottomNavToastPlacement; + onSelectBottomNav: (id: BottomNavId) => void; + onBackToMap: () => void; + onSwitchTab: (tab: "places" | "courses") => void; +}; + +function formatCount(count: number) { + return count > 999 ? "999+" : String(count); +} + +function filterChipClass(active: boolean) { + return cn(MAP_CHIP_BASE_CLASS, active ? MAP_CHIP_SELECTED_CLASS : MAP_CHIP_UNSELECTED_CLASS); +} + +export function PlaceListSavedCoursesPage({ + roomId = null, + roomName, + courses, + savedPlaces, + toastMessage, + toastPlacement = "bottom", + onSelectBottomNav, + onBackToMap, + onSwitchTab, +}: PlaceListSavedCoursesPageProps) { + const { data: roomsFromApi, isLoading: isRoomsLoading } = useRoomsQuery(); + const [savedCourses, setSavedCourses] = useState(courses); + const [selectedCourse, setSelectedCourse] = useState(null); + const [selectedFilter, setSelectedFilter] = useState("all"); + const [openPopup, setOpenPopup] = useState(null); + const [selectedRoomIds, setSelectedRoomIds] = useState([]); + const [selectedDate, setSelectedDate] = useState(null); + + const detailOpen = usePlaceDetailStore((s) => s.isOpen); + const selectedPlaceId = usePlaceDetailStore((s) => s.selectedPlaceId); + const closeDetail = usePlaceDetailStore((s) => s.closeDetail); + const filterChromeRef = useRef(null); + + const roomChipApplied = selectedRoomIds.length > 0; + const dateChipApplied = selectedDate !== null; + const allChipActive = !roomChipApplied && !dateChipApplied; + const overlayMapOpen = Boolean(selectedCourse) || detailOpen; + + 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 visibleCourses = useMemo(() => { + if (selectedFilter === "date" && selectedDate === "2025.04.26") { + return []; + } + + if (selectedFilter === "date" && selectedDate) { + return savedCourses.filter((course) => course.executedAtLabel.includes("04.20")).slice(0, 8); + } + + if (selectedFilter === "room" && selectedRoomIds.length > 0) { + return savedCourses.filter( + (course) => + course.savedFromRoomId != null && selectedRoomIds.includes(course.savedFromRoomId), + ); + } + + return savedCourses; + }, [savedCourses, selectedDate, selectedFilter, selectedRoomIds]); + + const mapPins = useMemo(() => { + return selectedCourse + ? mapPlacesFromSavedCourses([selectedCourse], savedPlaces) + : mapPlacesFromSavedCourses(visibleCourses, savedPlaces); + }, [savedPlaces, selectedCourse, visibleCourses]); + + const mapCenter = useMemo(() => { + if (detailOpen && selectedPlaceId) { + const pin = mapPins.find((place) => place.id === selectedPlaceId); + if (pin) { + return { latitude: pin.latitude, longitude: pin.longitude }; + } + } + + if (selectedCourse) { + const focusedPins = mapPlacesFromSavedCourses([selectedCourse], savedPlaces); + if (focusedPins.length > 0) { + return weightedMapCenter(focusedPins); + } + } + + return weightedMapCenter(mapPins); + }, [detailOpen, mapPins, savedPlaces, selectedCourse, selectedPlaceId]); + + const handleSelectAll = () => { + setSelectedFilter("all"); + setOpenPopup(null); + setSelectedRoomIds([]); + setSelectedDate(null); + }; + + const handleToggleRoom = (nextRoomId: string) => { + const nextIds = selectedRoomIds.includes(nextRoomId) + ? selectedRoomIds.filter((item) => item !== nextRoomId) + : [...selectedRoomIds, nextRoomId]; + setSelectedRoomIds(nextIds); + setSelectedFilter(nextIds.length === 0 ? "all" : "room"); + }; + + const handlePickCalendarDate = (date: string) => { + if (selectedDate === date) { + setSelectedDate(null); + setSelectedFilter("all"); + setOpenPopup(null); + return; + } + + setSelectedFilter("date"); + setSelectedDate(date); + setOpenPopup(null); + }; + + const handlePersistCourse = (prevCourseId: string, payload: CourseSavePayload) => { + if (payload.kind !== "edit") { + return; + } + + setSavedCourses((current) => + current.map((course) => + course.id === prevCourseId + ? { + ...course, + title: payload.title, + stops: payload.stops.map((stop) => ({ + id: stop.placeId, + name: stop.name, + address: stop.address, + walkingTime: stop.walkingTime, + hours: stop.hours, + })), + } + : course, + ), + ); + + setSelectedCourse((current) => + current?.id === prevCourseId + ? { + ...current, + title: payload.title, + stops: payload.stops.map((stop) => ({ + id: stop.placeId, + name: stop.name, + address: stop.address, + walkingTime: stop.walkingTime, + hours: stop.hours, + })), + } + : current, + ); + }; + + const titleNode = ( + + ); + + return ( +
+ {overlayMapOpen ? ( + + }> + 0 ? mapCenter : MAP_INITIAL_CENTER} + className="h-full w-full" + /> + + + ) : null} + + { + if (detailOpen) { + closeDetail(); + return; + } + if (selectedCourse) { + setSelectedCourse(null); + return; + } + onBackToMap(); + }} + > + {!overlayMapOpen ? ( +
+
+ + +
+ +
+ + +
+ + + {openPopup === "room" ? ( +
+ {isRoomsLoading ? ( +
방 목록 불러오는 중...
+ ) : roomsList.length === 0 ? ( +
참여 중인 방이 없습니다.
+ ) : ( + roomsList.map((room) => { + const checked = selectedRoomIds.includes(room.roomId); + return ( + + ); + }) + )} +
+ ) : null} +
+ +
+ + + {openPopup === "date" ? ( +
+ +
+ ) : null} +
+
+
+ ) : null} +
+ + {!overlayMapOpen ? ( +
+ {visibleCourses.length > 0 ? ( +
+ {visibleCourses.map((course) => ( +
+ +
+ ))} +
+ ) : ( + } + message="해당하는 데이트 코스가 없습니다." + /> + )} +
+ ) : null} + + {selectedCourse ? ( +
+ setSelectedCourse(null)} + onSave={(payload) => handlePersistCourse(selectedCourse.id, payload)} + hideNewCourseSaveButton + /> +
+ ) : null} + +
+ + +
+ + +
+ ); +} diff --git a/src/pages/tabs/PlaceListPage.tsx b/src/pages/tabs/PlaceListPage.tsx index b13f869..6a41734 100644 --- a/src/pages/tabs/PlaceListPage.tsx +++ b/src/pages/tabs/PlaceListPage.tsx @@ -12,6 +12,7 @@ import { MapBackdropLayer } from "@/components/common/MapBackdropLayer"; import { CoursePlannerBottomSheet } from "@/components/course-planner/CoursePlannerBottomSheet"; import { RegionSelectionPanel } from "@/components/course-planner/RegionSelectionPanel"; import { FilterBar } from "@/components/map/FilterBar"; +import { PlaceListSavedCoursesPage } from "@/components/place-list/PlaceListSavedCoursesPage"; import { weightedMapCenter } from "@/components/mypage/map-places-from-my-saved"; import { SavedPlaceItem } from "@/components/mypage/SavedPlaceItem"; import { PlaceDetailSheet } from "@/components/place/PlaceDetailSheet"; @@ -38,6 +39,7 @@ import { usePlaceDetailOpenEvent } from "@/hooks/use-place-detail-open-event"; import { usePointerDownOutside } from "@/hooks/use-pointer-down-outside"; import { cn } from "@/lib/utils"; import { APP_ROUTES } from "@/shared/config/routes"; +import { savedCourses as seedSavedCourses } from "@/shared/mocks/course-mocks"; import { PLACE_LIST_TEXT } from "@/shared/config/text"; import type { SavedPlace } from "@/shared/types/map-home"; import type { SavedPlace as MySavedPlace } from "@/shared/types/my-page"; @@ -69,6 +71,7 @@ export default function PlaceListPage() { const [draftSigungu, setDraftSigungu] = useState(REGION_ALL_OPTION); const [regionSearchKeyword, setRegionSearchKeyword] = useState(""); const [isRegionPanelOpen, setIsRegionPanelOpen] = useState(false); + const [activeTab, setActiveTab] = useState<"places" | "courses">("places"); const roomPlaceListParams = useMemo(() => { const hasSido = selectedSido.code !== REGION_ALL_CODE; const hasSigungu = hasSido && selectedSigungu.code !== REGION_ALL_CODE; @@ -206,6 +209,30 @@ export default function PlaceListPage() { })); }, [categoryFilteredPlaces]); + const savedPlacesForCourses = useMemo( + () => + listPlacesBase.map((place) => ({ + id: place.id, + name: place.name, + address: place.address, + category: place.category, + shareLinkUrl: place.shareLinkUrl, + roomId: effectiveRoomId ?? undefined, + memo: place.memo, + memos: place.memos, + })), + [effectiveRoomId, listPlacesBase], + ); + + const savedCourses = useMemo( + () => + seedSavedCourses.map((course) => ({ + ...course, + savedFromRoomId: effectiveRoomId ?? course.savedFromRoomId ?? null, + })), + [effectiveRoomId], + ); + const [openMenuId, setOpenMenuId] = useState(null); const [editingPlaceId, setEditingPlaceId] = useState(null); const [memoDraft, setMemoDraft] = useState(""); @@ -285,12 +312,28 @@ export default function PlaceListPage() { shownCount === regionTotal && regionTotal === categoryTotal ? `${formatCount(shownCount)}개` : shownCount === regionTotal - ? `${formatCount(shownCount)}개 · 전체 ${formatCount(categoryTotal)}` - : `${formatCount(shownCount)}개 · 전체 ${formatCount(regionTotal)}`; + ? `${formatCount(shownCount)}개 / 전체 ${formatCount(categoryTotal)}` + : `${formatCount(shownCount)}개 / 전체 ${formatCount(regionTotal)}`; const roomName = selectedRoom?.id === effectiveRoomId ? selectedRoom.name : roomDetailQuery.data?.roomName; const pageTitle = roomName ? `${roomName} 목록` : "목록"; + if (activeTab === "courses") { + return ( + navigate(APP_ROUTES.map)} + onSwitchTab={setActiveTab} + /> + ); + } + const hasRegionFilter = selectedSido.code !== REGION_ALL_CODE; const emptyMessage = roomPlacesQuery.isError ? "장소 목록을 불러오지 못했어요." @@ -400,6 +443,22 @@ export default function PlaceListPage() { > {!detailOpen ? (
+
+ + +
+
) : roomsList.length === 0 ? ( -
참여 중인 방이 없습니다.
+
+ 참여 중인 방이 없습니다. +
) : ( roomsList.map((room) => { const checked = selectedRoomIds.includes(room.roomId); @@ -378,7 +380,10 @@ export function PlaceListSavedCoursesPage({ "absolute right-0 z-40 mt-1! w-[16rem] rounded-lg! p-2", )} > - + ) : null} diff --git a/src/pages/tabs/PlaceListPage.tsx b/src/pages/tabs/PlaceListPage.tsx index cfaad48..4ef77e7 100644 --- a/src/pages/tabs/PlaceListPage.tsx +++ b/src/pages/tabs/PlaceListPage.tsx @@ -12,10 +12,10 @@ import { MapBackdropLayer } from "@/components/common/MapBackdropLayer"; import { CoursePlannerBottomSheet } from "@/components/course-planner/CoursePlannerBottomSheet"; import { RegionSelectionPanel } from "@/components/course-planner/RegionSelectionPanel"; import { FilterBar } from "@/components/map/FilterBar"; -import { PlaceListSavedCoursesPage } from "@/components/place-list/PlaceListSavedCoursesPage"; import { weightedMapCenter } from "@/components/mypage/map-places-from-my-saved"; import { SavedPlaceItem } from "@/components/mypage/SavedPlaceItem"; import { PlaceDetailSheet } from "@/components/place/PlaceDetailSheet"; +import { PlaceListSavedCoursesPage } from "@/components/place-list/PlaceListSavedCoursesPage"; import { RoomConfirmModal } from "@/components/room/RoomConfirmModal"; import { useMapSearchFilters } from "@/features/map/hooks/use-map-search-filters"; import { usePlaceFilterViewModel } from "@/features/map/hooks/use-place-filter-view-model"; @@ -40,8 +40,8 @@ import { usePlaceDetailOpenEvent } from "@/hooks/use-place-detail-open-event"; import { usePointerDownOutside } from "@/hooks/use-pointer-down-outside"; import { cn } from "@/lib/utils"; import { APP_ROUTES } from "@/shared/config/routes"; -import { savedCourses as seedSavedCourses } from "@/shared/mocks/course-mocks"; import { PLACE_LIST_TEXT } from "@/shared/config/text"; +import { savedCourses as seedSavedCourses } from "@/shared/mocks/course-mocks"; import type { SavedPlace } from "@/shared/types/map-home"; import type { SavedPlace as MySavedPlace } from "@/shared/types/my-page"; import { usePlaceDetailStore } from "@/store/place-detail-store"; From c3b0321c8a334d25a2ef0e47953acf8015e98a4d Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Thu, 4 Jun 2026 00:31:25 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=B0=A9=EB=B3=84=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9E=A5=EC=86=8C=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/SavedCourseCard.tsx | 58 ++- .../place-list/PlaceListSavedCoursesPage.tsx | 423 ++++++++++++------ .../course-planner/api/date-course-api.ts | 25 +- .../hooks/use-date-course-detail-query.ts | 41 ++ .../hooks/use-room-date-courses-query.ts | 46 ++ .../lib/map-my-saved-date-course.ts | 6 +- .../lib/map-room-saved-date-course.ts | 61 +++ src/pages/tabs/PlaceListPage.tsx | 17 +- src/shared/types/course.ts | 4 + 9 files changed, 499 insertions(+), 182 deletions(-) create mode 100644 src/features/course-planner/hooks/use-date-course-detail-query.ts create mode 100644 src/features/course-planner/hooks/use-room-date-courses-query.ts create mode 100644 src/features/course-planner/lib/map-room-saved-date-course.ts diff --git a/src/components/mypage/SavedCourseCard.tsx b/src/components/mypage/SavedCourseCard.tsx index 0c66f92..b4077a1 100644 --- a/src/components/mypage/SavedCourseCard.tsx +++ b/src/components/mypage/SavedCourseCard.tsx @@ -1,4 +1,5 @@ -import { ChevronRight, Heart, UsersRound } from "lucide-react"; +import { ChevronRight, Heart, User, UsersRound } from "lucide-react"; +import { useCallback, useState } from "react"; import { cn } from "@/lib/utils"; import type { SavedCourse } from "@/shared/types/course"; @@ -12,6 +13,8 @@ type SavedCourseCardProps = { export function SavedCourseCard({ course, onSelect, className }: SavedCourseCardProps) { const isFriendCourse = course.badgeLabel === "친구"; const Icon = isFriendCourse ? UsersRound : Heart; + const saverNickname = course.savedByNickname?.trim() ?? ""; + const hasSaver = saverNickname.length > 0 || Boolean(course.savedByProfileImageUrl?.trim()); return ( ); } + +function SaverAvatar({ imageUrl }: { imageUrl?: string | null }) { + const url = imageUrl?.trim() ?? ""; + const [failedUrl, setFailedUrl] = useState(null); + const showImage = Boolean(url) && failedUrl !== url; + + const handleImageError = useCallback(() => { + setFailedUrl(url); + }, [url]); + + if (showImage) { + return ( + + ); + } + + return ( + + + + ); +} diff --git a/src/components/place-list/PlaceListSavedCoursesPage.tsx b/src/components/place-list/PlaceListSavedCoursesPage.tsx index 771eb9c..fd05c24 100644 --- a/src/components/place-list/PlaceListSavedCoursesPage.tsx +++ b/src/components/place-list/PlaceListSavedCoursesPage.tsx @@ -1,12 +1,13 @@ -import { AlertCircle, Check, ChevronDown, ChevronRight } from "lucide-react"; +import { AlertCircle, Check, ChevronDown, User } from "lucide-react"; import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react"; -import { type BottomNavId,BottomNavigationBar } from "@/components/common/BottomNavigationBar"; +import { type BottomNavId, BottomNavigationBar } from "@/components/common/BottomNavigationBar"; import { BottomNavToast } from "@/components/common/BottomNavToast"; import { EmptyState } from "@/components/common/EmptyState"; import { LIST_TOP_BAR_AFTER_TITLE_CLASS, ListTopBar } from "@/components/common/ListTopBar"; import { MapBackdropLayer } from "@/components/common/MapBackdropLayer"; import { CoursePlaceInfoPanel } from "@/components/course-planner/CoursePlaceInfoPanel"; +import { CoursePlannerBottomSheet } from "@/components/course-planner/CoursePlannerBottomSheet"; import { DateCalendarPanel } from "@/components/course-planner/DateTimeSelectionPanel"; import { MAP_CHIP_BASE_CLASS, @@ -21,7 +22,9 @@ import { } from "@/components/mypage/saved-course-planner-map"; import { SavedCourseCard } from "@/components/mypage/SavedCourseCard"; import { PlaceDetailSheet } from "@/components/place/PlaceDetailSheet"; -import { useRoomsQuery } from "@/features/room"; +import { useDateCourseDetailQuery } from "@/features/course-planner/hooks/use-date-course-detail-query"; +import { useRoomDateCoursesQuery } from "@/features/course-planner/hooks/use-room-date-courses-query"; +import { mapRoomSavedDateCourseToSavedCourse } from "@/features/course-planner/lib/map-room-saved-date-course"; import type { BottomNavToastPlacement } from "@/hooks/use-bottom-nav-controller"; import { usePointerDownOutside } from "@/hooks/use-pointer-down-outside"; import { cn } from "@/lib/utils"; @@ -35,13 +38,18 @@ const KakaoMapView = lazy(() => import("@/components/map/KakaoMapView").then((module) => ({ default: module.KakaoMapView })), ); -type CourseFilter = "all" | "room" | "date"; +type CourseFilter = "all" | "member" | "date"; type FilterPopup = Exclude | null; +type MemberFilterOption = { + id: string; + nickname: string; + profileImageUrl: string | null; +}; + type PlaceListSavedCoursesPageProps = { roomId?: string | null; - roomName: string; - courses: SavedCourse[]; + roomName?: string; savedPlaces: SavedPlace[]; toastMessage: string; toastPlacement?: BottomNavToastPlacement; @@ -58,10 +66,13 @@ function filterChipClass(active: boolean) { return cn(MAP_CHIP_BASE_CLASS, active ? MAP_CHIP_SELECTED_CLASS : MAP_CHIP_UNSELECTED_CLASS); } +function formatDateLabel(date: string | null) { + return date ?? "날짜"; +} + export function PlaceListSavedCoursesPage({ roomId = null, roomName, - courses, savedPlaces, toastMessage, toastPlacement = "bottom", @@ -69,64 +80,110 @@ export function PlaceListSavedCoursesPage({ onBackToMap, onSwitchTab, }: PlaceListSavedCoursesPageProps) { - const { data: roomsFromApi, isLoading: isRoomsLoading } = useRoomsQuery(); - const [savedCourses, setSavedCourses] = useState(courses); const [selectedCourse, setSelectedCourse] = useState(null); - const [selectedFilter, setSelectedFilter] = useState("all"); + const [courseOverrides, setCourseOverrides] = useState>({}); + const [, setSelectedFilter] = useState("all"); const [openPopup, setOpenPopup] = useState(null); - const [selectedRoomIds, setSelectedRoomIds] = useState([]); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); const [selectedDate, setSelectedDate] = useState(null); + const roomDateCoursesQuery = useRoomDateCoursesQuery({ + roomId, + enabled: Boolean(roomId), + }); + const dateCourseDetailQuery = useDateCourseDetailQuery({ + roomId, + dateCourseId: selectedCourse?.id ?? null, + enabled: Boolean(selectedCourse), + }); const detailOpen = usePlaceDetailStore((s) => s.isOpen); const selectedPlaceId = usePlaceDetailStore((s) => s.selectedPlaceId); const closeDetail = usePlaceDetailStore((s) => s.closeDetail); const filterChromeRef = useRef(null); - const roomChipApplied = selectedRoomIds.length > 0; + const memberChipApplied = selectedMemberIds.length > 0; const dateChipApplied = selectedDate !== null; - const allChipActive = !roomChipApplied && !dateChipApplied; + const allChipActive = !memberChipApplied && !dateChipApplied; const overlayMapOpen = Boolean(selectedCourse) || detailOpen; const closeFilterPopups = useCallback(() => { setOpenPopup(null); setSelectedFilter((prev) => { if (prev === "date" && selectedDate === null) return "all"; - if (prev === "room" && selectedRoomIds.length === 0) return "all"; + if (prev === "member" && selectedMemberIds.length === 0) return "all"; return prev; }); - }, [selectedDate, selectedRoomIds]); + }, [selectedDate, selectedMemberIds.length]); 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 apiCourses = useMemo( + () => + (roomDateCoursesQuery.data?.items ?? []).map((course) => + mapRoomSavedDateCourseToSavedCourse(course, roomId), + ), + [roomDateCoursesQuery.data?.items, roomId], + ); - const visibleCourses = useMemo(() => { - if (selectedFilter === "date" && selectedDate === "2025.04.26") { - return []; - } + const savedCourses = useMemo( + () => apiCourses.map((course) => courseOverrides[course.id] ?? course), + [apiCourses, courseOverrides], + ); + + const memberOptions = useMemo((): MemberFilterOption[] => { + const byId = new Map(); + + for (const course of savedCourses) { + if (course.savedByUserId == null) { + continue; + } + + const nickname = course.savedByNickname?.trim(); + if (!nickname) { + continue; + } - if (selectedFilter === "date" && selectedDate) { - return savedCourses.filter((course) => course.executedAtLabel.includes("04.20")).slice(0, 8); + const id = String(course.savedByUserId); + if (!byId.has(id)) { + byId.set(id, { + id, + nickname, + profileImageUrl: course.savedByProfileImageUrl ?? null, + }); + } } - if (selectedFilter === "room" && selectedRoomIds.length > 0) { - return savedCourses.filter( - (course) => - course.savedFromRoomId != null && selectedRoomIds.includes(course.savedFromRoomId), - ); + return [...byId.values()]; + }, [savedCourses]); + + const selectedCourseWithDetail = useMemo(() => { + if (!selectedCourse) { + return null; } - return savedCourses; - }, [savedCourses, selectedDate, selectedFilter, selectedRoomIds]); + const detailCourse = dateCourseDetailQuery.data + ? mapRoomSavedDateCourseToSavedCourse(dateCourseDetailQuery.data, roomId) + : selectedCourse; + + return courseOverrides[detailCourse.id] ?? detailCourse; + }, [courseOverrides, dateCourseDetailQuery.data, roomId, selectedCourse]); + + const visibleCourses = useMemo(() => { + return savedCourses.filter((course) => { + const matchesMember = + selectedMemberIds.length === 0 || + (course.savedByUserId != null && selectedMemberIds.includes(String(course.savedByUserId))); + const matchesDate = selectedDate == null || course.courseDateKey === selectedDate; + + return matchesMember && matchesDate; + }); + }, [savedCourses, selectedDate, selectedMemberIds]); const mapPins = useMemo(() => { - return selectedCourse - ? mapPlacesFromSavedCourses([selectedCourse], savedPlaces) + return selectedCourseWithDetail + ? mapPlacesFromSavedCourses([selectedCourseWithDetail], savedPlaces) : mapPlacesFromSavedCourses(visibleCourses, savedPlaces); - }, [savedPlaces, selectedCourse, visibleCourses]); + }, [savedPlaces, selectedCourseWithDetail, visibleCourses]); const mapCenter = useMemo(() => { if (detailOpen && selectedPlaceId) { @@ -136,29 +193,31 @@ export function PlaceListSavedCoursesPage({ } } - if (selectedCourse) { - const focusedPins = mapPlacesFromSavedCourses([selectedCourse], savedPlaces); + if (selectedCourseWithDetail) { + const focusedPins = mapPlacesFromSavedCourses([selectedCourseWithDetail], savedPlaces); if (focusedPins.length > 0) { return weightedMapCenter(focusedPins); } } return weightedMapCenter(mapPins); - }, [detailOpen, mapPins, savedPlaces, selectedCourse, selectedPlaceId]); + }, [detailOpen, mapPins, savedPlaces, selectedCourseWithDetail, selectedPlaceId]); const handleSelectAll = () => { setSelectedFilter("all"); setOpenPopup(null); - setSelectedRoomIds([]); + setSelectedMemberIds([]); setSelectedDate(null); }; - const handleToggleRoom = (nextRoomId: string) => { - const nextIds = selectedRoomIds.includes(nextRoomId) - ? selectedRoomIds.filter((item) => item !== nextRoomId) - : [...selectedRoomIds, nextRoomId]; - setSelectedRoomIds(nextIds); - setSelectedFilter(nextIds.length === 0 ? "all" : "room"); + const handleToggleMember = (memberId: string) => { + setSelectedMemberIds((current) => { + const nextIds = current.includes(memberId) + ? current.filter((item) => item !== memberId) + : [...current, memberId]; + setSelectedFilter(nextIds.length === 0 && selectedDate == null ? "all" : "member"); + return nextIds; + }); }; const handlePickCalendarDate = (date: string) => { @@ -179,53 +238,34 @@ export function PlaceListSavedCoursesPage({ return; } - setSavedCourses((current) => - current.map((course) => - course.id === prevCourseId - ? { - ...course, - title: payload.title, - stops: payload.stops.map((stop) => ({ - id: stop.placeId, - name: stop.name, - address: stop.address, - walkingTime: stop.walkingTime, - hours: stop.hours, - })), - } - : course, - ), - ); + const source = savedCourses.find((course) => course.id === prevCourseId) ?? selectedCourse; + if (!source) { + return; + } - setSelectedCourse((current) => - current?.id === prevCourseId - ? { - ...current, - title: payload.title, - stops: payload.stops.map((stop) => ({ - id: stop.placeId, - name: stop.name, - address: stop.address, - walkingTime: stop.walkingTime, - hours: stop.hours, - })), - } - : current, - ); + const nextCourse: SavedCourse = { + ...source, + title: payload.title, + stops: payload.stops.map((stop) => ({ + id: stop.id, + roomPlaceId: stop.roomPlaceId, + name: stop.name, + address: stop.address, + category: stop.category, + categoryName: stop.categoryName, + tagCode: stop.tagCode, + tagName: stop.tagName, + latitude: stop.latitude, + longitude: stop.longitude, + walkingTime: stop.walkingTime, + hours: stop.hours, + })), + }; + + setCourseOverrides((current) => ({ ...current, [prevCourseId]: nextCourse })); + setSelectedCourse(nextCourse); }; - const titleNode = ( - - ); - return (
{overlayMapOpen ? ( @@ -242,8 +282,8 @@ export function PlaceListSavedCoursesPage({ ) : null} { - setSelectedFilter("room"); - setOpenPopup((current) => (current === "room" ? null : "room")); + setSelectedFilter("member"); + setOpenPopup((current) => (current === "member" ? null : "member")); }} - className={cn(filterChipClass(roomChipApplied), "shrink-0 gap-1 px-3")} - aria-expanded={openPopup === "room"} + className={cn(filterChipClass(memberChipApplied), "shrink-0 gap-1 px-3")} + aria-expanded={openPopup === "member"} + aria-haspopup="listbox" + aria-pressed={memberChipApplied} > - 방 + 멤버 - {openPopup === "room" ? ( + {openPopup === "member" ? (
- {isRoomsLoading ? ( -
방 목록 불러오는 중...
- ) : roomsList.length === 0 ? ( -
- 참여 중인 방이 없습니다. + {roomDateCoursesQuery.isLoading ? ( +
+ {Array.from({ length: 3 }, (_, index) => ( +
+
+
+
+
+ ))} +
+ ) : memberOptions.length === 0 ? ( +
+ 저장한 멤버가 없습니다
) : ( - roomsList.map((room) => { - const checked = selectedRoomIds.includes(room.roomId); - return ( - - ); - }) + + + ); + })} + )}
) : null} @@ -360,27 +441,30 @@ export function PlaceListSavedCoursesPage({ setSelectedFilter("date"); setOpenPopup((current) => (current === "date" ? null : "date")); }} - className={cn(filterChipClass(dateChipApplied), "shrink-0 gap-1 px-3")} + className={cn( + filterChipClass(dateChipApplied), + "max-w-[min(11rem,calc(100vw-8rem))] shrink-0 gap-1 truncate px-3", + )} aria-expanded={openPopup === "date"} + aria-haspopup="dialog" + aria-pressed={dateChipApplied} > - {selectedDate ?? "날짜"} + {formatDateLabel(selectedDate)} {openPopup === "date" ? ( -
+
@@ -394,37 +478,44 @@ export function PlaceListSavedCoursesPage({ {!overlayMapOpen ? (
- {visibleCourses.length > 0 ? ( + {roomDateCoursesQuery.isLoading ? ( + } + message="저장된 데이트 코스를 불러오는 중이에요." + /> + ) : visibleCourses.length > 0 ? (
{visibleCourses.map((course) => ( -
- -
+ ))}
) : ( } - message="해당하는 데이트 코스가 없습니다." + message={ + roomDateCoursesQuery.isError + ? "저장된 데이트 코스를 불러오지 못했어요." + : "해당하는 데이트 코스가 없습니다." + } /> )}
) : null} - {selectedCourse ? ( -
+ setSelectedCourse(null)} + > + {selectedCourseWithDetail ? ( setSelectedCourse(null)} - onSave={(payload) => handlePersistCourse(selectedCourse.id, payload)} + onSave={(payload) => handlePersistCourse(selectedCourseWithDetail.id, payload)} hideNewCourseSaveButton /> -
- ) : null} + ) : null} +
@@ -435,3 +526,37 @@ export function PlaceListSavedCoursesPage({
); } + +function MemberAvatar({ imageUrl, className }: { imageUrl?: string | null; className?: string }) { + const url = imageUrl?.trim() ?? ""; + const [failedUrl, setFailedUrl] = useState(null); + const showImage = Boolean(url) && failedUrl !== url; + + const handleImageError = useCallback(() => { + setFailedUrl(url); + }, [url]); + + if (showImage) { + return ( + + ); + } + + return ( + + + + ); +} diff --git a/src/features/course-planner/api/date-course-api.ts b/src/features/course-planner/api/date-course-api.ts index 380b8d2..e738c20 100644 --- a/src/features/course-planner/api/date-course-api.ts +++ b/src/features/course-planner/api/date-course-api.ts @@ -72,15 +72,17 @@ export type GenerateDateCoursesResponse = { export type SavedRoomDateCourseItemResponse = { dateCourseId: string; - courseName: string; - mode: DateCourseMode; - startDateTime: string; - endDateTime: string; - savedByUserId: string; - savedByNickname: string; - savedAt: string; - places: DateCoursePlaceResponse[]; - orderedCoordinates: DateCourseCoordinateResponse[]; + courseName: string | null; + mode?: DateCourseMode | null; + startDateTime?: string | null; + endDateTime?: string | null; + savedByUserId: number | string | null; + savedByNickname: string | null; + savedByProfileImageUrl: string | null; + savedAt: string | null; + roomPublicId?: string | null; + places?: DateCoursePlaceResponse[]; + orderedCoordinates?: DateCourseCoordinateResponse[]; }; export type DateCourseListResponse = { @@ -97,6 +99,11 @@ export type DateCourseDetailResponse = { mode: DateCourseMode; startDateTime: string; endDateTime: string; + savedByUserId?: number | string | null; + savedByNickname?: string | null; + savedByProfileImageUrl?: string | null; + savedAt?: string | null; + roomPublicId?: string | null; places: DateCoursePlaceResponse[]; orderedCoordinates: DateCourseCoordinateResponse[]; }; diff --git a/src/features/course-planner/hooks/use-date-course-detail-query.ts b/src/features/course-planner/hooks/use-date-course-detail-query.ts new file mode 100644 index 0000000..f8aff9a --- /dev/null +++ b/src/features/course-planner/hooks/use-date-course-detail-query.ts @@ -0,0 +1,41 @@ +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; + +import { + dateCourseApi, + type DateCourseDetailResponse, +} from "@/features/course-planner/api/date-course-api"; +import { dateCourseQueryKeys } from "@/features/course-planner/query-keys"; + +type UseDateCourseDetailQueryOptions = { + roomId?: string | null; + dateCourseId?: string | null; + enabled?: boolean; + queryOptions?: Omit< + UseQueryOptions< + DateCourseDetailResponse, + Error, + DateCourseDetailResponse, + ReturnType + >, + "queryKey" | "queryFn" | "enabled" + >; +}; + +export function useDateCourseDetailQuery({ + roomId, + dateCourseId, + enabled = true, + queryOptions, +}: UseDateCourseDetailQueryOptions) { + const resolvedRoomId = roomId ?? ""; + const resolvedDateCourseId = dateCourseId ?? ""; + + return useQuery({ + queryKey: dateCourseQueryKeys.detail(resolvedRoomId, resolvedDateCourseId), + queryFn: () => dateCourseApi.getDateCourseDetail(resolvedRoomId, resolvedDateCourseId), + staleTime: 1000 * 60, + retry: 1, + enabled: enabled && Boolean(roomId) && Boolean(dateCourseId), + ...(queryOptions ?? {}), + }); +} diff --git a/src/features/course-planner/hooks/use-room-date-courses-query.ts b/src/features/course-planner/hooks/use-room-date-courses-query.ts new file mode 100644 index 0000000..2c81500 --- /dev/null +++ b/src/features/course-planner/hooks/use-room-date-courses-query.ts @@ -0,0 +1,46 @@ +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; + +import { + dateCourseApi, + type DateCourseListResponse, + type SavedRoomDateCourseItemResponse, +} from "@/features/course-planner/api/date-course-api"; +import { dateCourseQueryKeys } from "@/features/course-planner/query-keys"; + +const DEFAULT_PAGE = 0; +const DEFAULT_LIMIT = 100; + +type UseRoomDateCoursesQueryOptions = { + roomId?: string | null; + page?: number; + limit?: number; + enabled?: boolean; + queryOptions?: Omit< + UseQueryOptions< + DateCourseListResponse, + Error, + DateCourseListResponse, + ReturnType + >, + "queryKey" | "queryFn" | "enabled" + >; +}; + +export function useRoomDateCoursesQuery({ + roomId, + page = DEFAULT_PAGE, + limit = DEFAULT_LIMIT, + enabled = true, + queryOptions, +}: UseRoomDateCoursesQueryOptions) { + const resolvedRoomId = roomId ?? ""; + + return useQuery({ + queryKey: dateCourseQueryKeys.roomList(resolvedRoomId, page, limit), + queryFn: () => dateCourseApi.listRoomDateCourses(resolvedRoomId, { page, limit }), + staleTime: 1000 * 60, + retry: 1, + enabled: enabled && Boolean(roomId), + ...(queryOptions ?? {}), + }); +} diff --git a/src/features/course-planner/lib/map-my-saved-date-course.ts b/src/features/course-planner/lib/map-my-saved-date-course.ts index 74af311..9eca55b 100644 --- a/src/features/course-planner/lib/map-my-saved-date-course.ts +++ b/src/features/course-planner/lib/map-my-saved-date-course.ts @@ -11,7 +11,11 @@ function toCoordinateNumber(value: string | number | null | undefined) { } /** 캘린더 필터(`yyyy.MM.dd`)와 맞추기 위한 키 */ -export function toCourseDateKey(isoInstant: string) { +export function toCourseDateKey(isoInstant: string | null | undefined) { + if (!isoInstant) { + return null; + } + const date = new Date(isoInstant); if (Number.isNaN(date.getTime())) { return null; diff --git a/src/features/course-planner/lib/map-room-saved-date-course.ts b/src/features/course-planner/lib/map-room-saved-date-course.ts new file mode 100644 index 0000000..942759f --- /dev/null +++ b/src/features/course-planner/lib/map-room-saved-date-course.ts @@ -0,0 +1,61 @@ +import type { + DateCourseDetailResponse, + DateCoursePlaceResponse, + SavedRoomDateCourseItemResponse, +} from "@/features/course-planner/api/date-course-api"; +import { + formatSavedAtLabel, + toCourseDateKey, +} from "@/features/course-planner/lib/map-my-saved-date-course"; +import type { SavedCourse, SavedCourseStop } from "@/shared/types/course"; + +function toCoordinateNumber(value: string | number | null | undefined) { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function mapPlaceToSavedCourseStop(place: DateCoursePlaceResponse): SavedCourseStop { + return { + id: String(place.roomPlaceId), + roomPlaceId: place.roomPlaceId, + name: place.name.trim(), + address: place.roadAddress?.trim() || place.address.trim(), + category: place.categoryCode, + categoryName: place.categoryName ?? null, + tagCode: place.tagCode, + tagName: place.tagName ?? null, + latitude: toCoordinateNumber(place.latitude), + longitude: toCoordinateNumber(place.longitude), + }; +} + +export function mapRoomSavedDateCourseToSavedCourse( + item: SavedRoomDateCourseItemResponse | DateCourseDetailResponse, + roomId?: string | null, +): SavedCourse { + const courseName = item.courseName?.trim() || "저장된 데이트 코스"; + const savedAt = item.savedAt ?? null; + const places = item.places ?? []; + + return { + id: item.dateCourseId, + title: courseName, + executedAtLabel: savedAt ? formatSavedAtLabel(savedAt) : "저장된 코스", + badgeLabel: item.savedByNickname ? "친구" : "하트", + savedByUserId: item.savedByUserId ?? null, + savedByNickname: item.savedByNickname ?? null, + savedByProfileImageUrl: item.savedByProfileImageUrl ?? null, + savedAt, + savedFromRoomId: item.roomPublicId ?? roomId ?? null, + courseDateKey: + toCourseDateKey(item.startDateTime) ?? (savedAt ? toCourseDateKey(savedAt) : null), + stops: places + .slice() + .sort((left, right) => left.sequenceOrder - right.sequenceOrder) + .map(mapPlaceToSavedCourseStop), + }; +} diff --git a/src/pages/tabs/PlaceListPage.tsx b/src/pages/tabs/PlaceListPage.tsx index 4ef77e7..62b071d 100644 --- a/src/pages/tabs/PlaceListPage.tsx +++ b/src/pages/tabs/PlaceListPage.tsx @@ -41,7 +41,6 @@ import { usePointerDownOutside } from "@/hooks/use-pointer-down-outside"; import { cn } from "@/lib/utils"; import { APP_ROUTES } from "@/shared/config/routes"; import { PLACE_LIST_TEXT } from "@/shared/config/text"; -import { savedCourses as seedSavedCourses } from "@/shared/mocks/course-mocks"; import type { SavedPlace } from "@/shared/types/map-home"; import type { SavedPlace as MySavedPlace } from "@/shared/types/my-page"; import { usePlaceDetailStore } from "@/store/place-detail-store"; @@ -235,15 +234,6 @@ export default function PlaceListPage() { [effectiveRoomId, listPlacesBase], ); - const savedCourses = useMemo( - () => - seedSavedCourses.map((course) => ({ - ...course, - savedFromRoomId: effectiveRoomId ?? course.savedFromRoomId ?? null, - })), - [effectiveRoomId], - ); - const [openMenuId, setOpenMenuId] = useState(null); const [editingPlaceId, setEditingPlaceId] = useState(null); const [memoDraft, setMemoDraft] = useState(""); @@ -327,14 +317,13 @@ export default function PlaceListPage() { : `${formatCount(shownCount)}개 / 전체 ${formatCount(regionTotal)}`; const roomName = selectedRoom?.id === effectiveRoomId ? selectedRoom.name : roomDetailQuery.data?.roomName; - const pageTitle = roomName ? `${roomName} 목록` : "목록"; + const pageTitle = roomName; if (activeTab === "courses") { return ( {!detailOpen ? ( -
+