diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index ca69e43..7078df7 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -30,6 +30,7 @@ export const router = createBrowserRouter([ { path: "dev/splash", element: }, { path: "dev/click_place", element: }, { path: "dev/SelectOption", element: }, + { path: "dev/list", element: }, { path: "dev/mypage", element: }, { path: "login", element: }, { path: "dev/course", element: }, diff --git a/src/components/course-planner/RegionSelectionPanel.tsx b/src/components/course-planner/RegionSelectionPanel.tsx index c96df54..5563d1b 100644 --- a/src/components/course-planner/RegionSelectionPanel.tsx +++ b/src/components/course-planner/RegionSelectionPanel.tsx @@ -14,7 +14,7 @@ type RegionSelectionPanelProps = { const cities = ["서울", "경기", "인천", "부산", "대구", "대전"]; const districtsByCity: Record = { - 서울: ["전체", "강남구", "강동구", "강북구", "강서구", "관악구"], + 서울: ["전체", "강남구", "강동구", "강북구", "강서구", "관악구", "동대문구"], 경기: ["전체", "성남시", "수원시", "고양시", "용인시", "하남시"], 인천: ["전체", "남동구", "연수구", "부평구", "서구", "중구"], 부산: ["전체", "해운대구", "수영구", "부산진구", "동래구", "남구"], diff --git a/src/components/mypage/SavedPlaceItem.tsx b/src/components/mypage/SavedPlaceItem.tsx index 796133c..2c2a5c0 100644 --- a/src/components/mypage/SavedPlaceItem.tsx +++ b/src/components/mypage/SavedPlaceItem.tsx @@ -8,23 +8,26 @@ 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; + /** true면 마이페이지용 메뉴·메모 편집 없이 카드만 표시(목록 탭 등) */ + readOnly?: boolean; + 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, + readOnly = false, + isMenuOpen = false, + isEditing = false, + memoDraft = "", onToggleMenu, onStartMemo, onChangeMemo, @@ -35,9 +38,9 @@ export function SavedPlaceItem({ }: SavedPlaceItemProps) { const menuChromeRef = useRef(null); const closeMenu = useCallback(() => { - onToggleMenu(place.id); + onToggleMenu?.(place.id); }, [onToggleMenu, place.id]); - usePointerDownOutside(menuChromeRef, isMenuOpen, closeMenu); + usePointerDownOutside(menuChromeRef, !readOnly && isMenuOpen, closeMenu); return (
@@ -47,35 +50,37 @@ export function SavedPlaceItem({

{place.address}

-
- + {!readOnly ? ( +
+ - {isMenuOpen ? ( -
- - -
- ) : null} -
+ {isMenuOpen ? ( +
+ + +
+ ) : null} +
+ ) : null} {place.memo && !isEditing ? ( @@ -84,12 +89,12 @@ export function SavedPlaceItem({

) : null} - {isEditing ? ( + {!readOnly && isEditing ? ( {})} + onSave={onSaveMemo ?? (() => {})} + onClear={onClearMemo ?? (() => {})} /> ) : null}
diff --git a/src/components/place-list/place-list-mock-data.ts b/src/components/place-list/place-list-mock-data.ts new file mode 100644 index 0000000..23fce8a --- /dev/null +++ b/src/components/place-list/place-list-mock-data.ts @@ -0,0 +1,4 @@ +export const PLACE_LIST_TEXT = { + emptySaved: "장소를 저장해 보세요!", + emptyFiltered: "해당하는 장소가 없습니다.", +}; diff --git a/src/pages/tabs/PlaceListPage.tsx b/src/pages/tabs/PlaceListPage.tsx index abf1939..d2bc3ac 100644 --- a/src/pages/tabs/PlaceListPage.tsx +++ b/src/pages/tabs/PlaceListPage.tsx @@ -1,26 +1,387 @@ -import { Navigate } from "react-router-dom"; +import "@/components/map/filter-bar.css"; + +import { AlertCircle, ArrowLeft, MapPin } from "lucide-react"; +import { lazy, Suspense, useMemo, useRef, useState } from "react"; import { BottomNavigationBar } from "@/components/common/BottomNavigationBar"; import { BottomNavToast } from "@/components/common/BottomNavToast"; +import { CoursePlannerBottomSheet } from "@/components/course-planner/CoursePlannerBottomSheet"; +import { RegionSelectionPanel } from "@/components/course-planner/RegionSelectionPanel"; +import { FilterBar } from "@/components/map/FilterBar"; +import { + mapPlacesMatchingMySaved, + weightedMapCenter, +} from "@/components/mypage/map-places-from-my-saved"; +import type { SavedPlace } from "@/components/mypage/mypage-mock-data"; +import { SavedPlaceItem } from "@/components/mypage/SavedPlaceItem"; +import { PlaceDetailSheet } from "@/components/place/PlaceDetailSheet"; +import { PLACE_LIST_TEXT } from "@/components/place-list/place-list-mock-data"; +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 { useBottomNavController } from "@/hooks/use-bottom-nav-controller"; -import { useRoomSelectionStore } from "@/store/room-selection-store"; +import { usePlaceDetailOpenEvent } from "@/hooks/use-place-detail-open-event"; +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 } from "@/shared/types/map-home"; +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 })), +); + +function formatCount(count: number) { + return count > 999 ? "999+" : String(count); +} + +function placeMatchesRegion(placeAddress: string, city: string, district: string) { + const matchesCity = placeAddress.includes(city); + const matchesDistrict = district === "전체" || placeAddress.includes(district); + return matchesCity && matchesDistrict; +} export default function PlaceListPage() { - const selectedRoom = useRoomSelectionStore((s) => s.selectedRoom); + const now = useKoreanNow(); const { toastMessage, toastPlacement, handleSelectBottomNav } = useBottomNavController(); - if (!selectedRoom) { - return ; - } + const resolvedPlaces = useMemo( + () => resolveSavedPlacesBusinessHours(SAVED_PLACE_MOCKS, now), + [now], + ); + + 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 { + activeCategories, + focusedCategory, + toggleCategory, + closeTagPanel, + isTagPanelOpen, + selectedTagKeysByCategory, + selectedTagCountByCategory, + toggleTagInCategory, + resetFocusedCategoryTags, + filteredPlaces, + } = useMapSearchFilters({ + places: resolvedPlaces, + filterCategories, + categoriesOnly: true, + }); + + const [selectedCity, setSelectedCity] = useState("서울"); + const [selectedDistrict, setSelectedDistrict] = useState("동대문구"); + const [draftCity, setDraftCity] = useState("서울"); + const [draftDistrict, setDraftDistrict] = useState("동대문구"); + const [isRegionPanelOpen, setIsRegionPanelOpen] = useState(false); + + const categoryFilteredPlaces = filteredPlaces; + + const listPlacesBase = useMemo((): SavedPlace[] => { + return categoryFilteredPlaces + .filter((place) => placeMatchesRegion(place.address, selectedCity, selectedDistrict)) + .map((place) => ({ + id: place.id, + name: place.name, + address: place.address, + category: place.category, + tagKeys: place.tagKeys, + })); + }, [categoryFilteredPlaces, selectedCity, selectedDistrict]); + + const [openMenuId, setOpenMenuId] = useState(null); + const [editingPlaceId, setEditingPlaceId] = useState(null); + const [memoDraft, setMemoDraft] = useState(""); + const [placeMemos, setPlaceMemos] = useState>({}); + const [removedPlaceIds, setRemovedPlaceIds] = useState([]); + + const displayedPlaces = useMemo((): SavedPlace[] => { + const removed = new Set(removedPlaceIds); + return listPlacesBase + .filter((place) => !removed.has(place.id)) + .map((place) => ({ + ...place, + memo: placeMemos[place.id] ?? place.memo, + })); + }, [listPlacesBase, placeMemos, removedPlaceIds]); + + const mapPins = useMemo( + () => resolveSavedPlacesBusinessHours(mapPlacesMatchingMySaved(displayedPlaces), now), + [displayedPlaces, now], + ); + + const detailOpen = usePlaceDetailStore((s) => s.isOpen); + const selectedPlaceId = usePlaceDetailStore((s) => s.selectedPlaceId); + const closeDetail = usePlaceDetailStore((s) => s.closeDetail); + const openPlaceDetail = usePlaceDetailStore((s) => s.openDetail); + + usePlaceDetailOpenEvent(true); + + 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 regionFieldValue = + selectedDistrict === "전체" ? selectedCity : `${selectedCity} ${selectedDistrict}`; + + const handleOpenRegionSelect = () => { + closeTagPanel(); + setDraftCity(selectedCity); + setDraftDistrict(selectedDistrict); + setIsRegionPanelOpen(true); + }; + + const handleSelectDraftCity = (city: string) => { + setDraftCity(city); + setDraftDistrict("전체"); + }; + + const handleConfirmRegion = () => { + setSelectedCity(draftCity); + setSelectedDistrict(draftDistrict); + setIsRegionPanelOpen(false); + }; + + const shownCount = displayedPlaces.length; + const regionTotal = listPlacesBase.length; + const categoryTotal = categoryFilteredPlaces.length; + const displayedCountLabel = + shownCount === regionTotal && regionTotal === categoryTotal + ? `${formatCount(shownCount)}개` + : shownCount === regionTotal + ? `${formatCount(shownCount)}개 · 전체 ${formatCount(categoryTotal)}` + : `${formatCount(shownCount)}개 · 전체 ${formatCount(regionTotal)}`; + + const emptyMessage = + resolvedPlaces.length === 0 ? PLACE_LIST_TEXT.emptySaved : PLACE_LIST_TEXT.emptyFiltered; + + const handleHeaderBack = () => { + if (detailOpen) { + closeDetail(); + } + }; + + const handleStartMemo = (place: SavedPlace) => { + setOpenMenuId(null); + setEditingPlaceId(place.id); + setMemoDraft(place.memo ?? ""); + }; + + const handleSaveMemo = () => { + if (!editingPlaceId) { + return; + } + + const nextMemo = memoDraft.trim(); + setPlaceMemos((previous) => { + const next = { ...previous }; + if (nextMemo) { + next[editingPlaceId] = nextMemo; + } else { + delete next[editingPlaceId]; + } + return next; + }); + setEditingPlaceId(null); + setMemoDraft(""); + }; + + const handleDeletePlace = (id: string) => { + setRemovedPlaceIds((previous) => (previous.includes(id) ? previous : [...previous, id])); + setOpenMenuId(null); + setPlaceMemos((previous) => { + if (!(id in previous)) { + return previous; + } + const next = { ...previous }; + delete next[id]; + return next; + }); + if (editingPlaceId === id) { + setEditingPlaceId(null); + setMemoDraft(""); + } + if (selectedPlaceId === id) { + closeDetail(); + } + }; return (
-
+ {detailOpen ? ( +
+ }> + + +
+ ) : null} + +
+
+ {detailOpen ? ( + + ) : ( + + )} +

+ 목록 +

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

{emptyMessage}

+
+ )} +
+ ) : null}
+ +
); }