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}
+
+
+
+
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)}
+ />
+ ))}
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+
);
}