From d9c4ef4b0947980205608452e7fde93240c66a2f Mon Sep 17 00:00:00 2001 From: yuji1202 Date: Wed, 27 May 2026 14:14:26 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=EC=A7=9C=EA=B8=B0=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EB=B0=8F=20=EC=88=9C=EC=84=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=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 추가 - 카테고리별 세부 태그 선택 UI 추가 - 순서 추가 및 삭제 기능 반영 - 생성 활성화 조건 반영 - /dev/course 임시 확인 경로 추가 --- src/app/router/index.tsx | 2 + .../course-planner/CoursePlannerPanel.tsx | 150 ++++++++++++++-- .../course-planner/PlaceTypeChip.tsx | 40 +++++ src/pages/tabs/CoursePlannerPage.tsx | 169 +++++++++--------- 4 files changed, 268 insertions(+), 93 deletions(-) create mode 100644 src/components/course-planner/PlaceTypeChip.tsx diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 8750823..eb7c8e3 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -19,6 +19,7 @@ import TermsAgreementPage from "@/pages/onboarding/TermsAgreementPage"; import RoomLinkCandidatesPage from "@/pages/rooms/RoomLinkCandidatesPage"; import RoomPlaceFromLinkPage from "@/pages/rooms/RoomPlaceFromLinkPage"; import RoomPlaceSearchPage from "@/pages/rooms/RoomPlaceSearchPage"; +import CoursePlannerPagePreview from "@/pages/tabs/CoursePlannerPage"; const MapHomePage = lazy(() => import("@/pages/MapHomePage")); const RoomMainPage = lazy(() => import("@/pages/room/RoomMainPage")); @@ -37,6 +38,7 @@ export const router = createBrowserRouter([ { path: "login", element: }, { path: "privacys", element: }, { path: "terms", element: }, + { path: "dev/course", element: }, { path: "auth/callback", element: , diff --git a/src/components/course-planner/CoursePlannerPanel.tsx b/src/components/course-planner/CoursePlannerPanel.tsx index ad78c4c..ab7206b 100644 --- a/src/components/course-planner/CoursePlannerPanel.tsx +++ b/src/components/course-planner/CoursePlannerPanel.tsx @@ -1,40 +1,84 @@ -import { CalendarDays } from "lucide-react"; +import type { ReactNode } from "react"; +import { CalendarDays, Coffee, Flag, Plus, Trash2, Utensils } from "lucide-react"; -import { CoursePlaceTagSelector } from "@/components/course-planner/CoursePlaceTagSelector"; import { CoursePlannerActions } from "@/components/course-planner/CoursePlannerActions"; import { CoursePlannerField } from "@/components/course-planner/CoursePlannerField"; -import type { MapFilterBarProps } from "@/components/map/filters/map-filter-bar-props"; +import { PlaceTypeChip } from "@/components/course-planner/PlaceTypeChip"; +import { cn } from "@/lib/utils"; + +export type PlaceTypeId = "restaurant" | "cafe" | "activity"; + +export type CourseCategoryOrder = { + id: number; + category: PlaceTypeId; + tags: string[]; +}; type CoursePlannerPanelProps = { regionValue: string; dateTimeValue: string; + courseOrders: CourseCategoryOrder[]; canGenerate: boolean; - placeFilterBarProps: MapFilterBarProps; onOpenRegionSelect: () => void; onOpenDateTimeSelect: () => void; + onCreateFirstOrder: (placeTypeId: PlaceTypeId) => void; + onAddOrder: () => void; + onRemoveOrder: (orderId: number) => void; + onSelectOrderCategory: (orderId: number, placeTypeId: PlaceTypeId) => void; + onToggleOrderTag: (orderId: number, tag: string) => void; onGenerate: () => void; onReset: () => void; }; +const placeTypes: Array<{ id: PlaceTypeId; label: string; icon: ReactNode }> = [ + { id: "restaurant", label: "음식점", icon: }, + { id: "cafe", label: "카페", icon: }, + { id: "activity", label: "놀거리", icon: }, +]; + +const categoryTags: Record = { + restaurant: ["전체", "한식", "중식", "일식", "양식", "분식", "아시아식", "술집", "기타"], + cafe: ["제과, 베이커리", "디저트 카페", "브런치 카페", "루프탑 카페", "보드카페", "만화카페"], + activity: [ + "전체", + "테마파크", + "보드카페", + "만화카페", + "문화,예술", + "방탈출카페", + "스포츠", + "찜질방", + "공원", + "생활용품점", + "아쿠아리움", + "기타", + ], +}; + export function CoursePlannerPanel({ regionValue, dateTimeValue, + courseOrders, canGenerate, - placeFilterBarProps, onOpenRegionSelect, onOpenDateTimeSelect, + onCreateFirstOrder, + onAddOrder, + onRemoveOrder, + onSelectOrderCategory, + onToggleOrderTag, onGenerate, onReset, }: CoursePlannerPanelProps) { return (

- 맞춤 데이트 코스 만들기 + 맞춤 데이트코스 설정하기

} @@ -50,8 +94,94 @@ export function CoursePlannerPanel({ />
- 장소 유형 - +
+ + 데이트 카테고리 및 순서 설정 + * + + + {courseOrders.length > 0 ? ( + + ) : null} +
+ + {courseOrders.length === 0 ? ( +
+ {placeTypes.map((type) => ( + onCreateFirstOrder(type.id)} + /> + ))} +
+ ) : ( +
+ {courseOrders.map((order, index) => ( +
+
+ + {index + 1} + + + +
+ +
+ {placeTypes.map((type) => ( + onSelectOrderCategory(order.id, type.id)} + /> + ))} +
+ +
+ {categoryTags[order.category].map((tag) => { + const selected = order.tags.includes(tag); + return ( + + ); + })} +
+
+ ))} +
+ )}
diff --git a/src/components/course-planner/PlaceTypeChip.tsx b/src/components/course-planner/PlaceTypeChip.tsx new file mode 100644 index 0000000..b332156 --- /dev/null +++ b/src/components/course-planner/PlaceTypeChip.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +type PlaceTypeChipProps = { + label: string; + icon: ReactNode; + selected?: boolean; + selectedStyle?: "accent" | "muted"; + onClick?: () => void; +}; + +export function PlaceTypeChip({ + label, + icon, + selected = false, + selectedStyle = "accent", + onClick, +}: PlaceTypeChipProps) { + const selectedClassName = selectedStyle === "muted" + ? "border-[#d9d9d9] bg-[#f1f1f1] text-[#4b5563]" + : "border-[#f06f6b] bg-[#fff0ee] text-[#d95f5b]"; + + return ( + + ); +} \ No newline at end of file diff --git a/src/pages/tabs/CoursePlannerPage.tsx b/src/pages/tabs/CoursePlannerPage.tsx index 1088a77..e3c38bf 100644 --- a/src/pages/tabs/CoursePlannerPage.tsx +++ b/src/pages/tabs/CoursePlannerPage.tsx @@ -5,7 +5,11 @@ import { BottomNavToast } from "@/components/common/BottomNavToast"; import { CourseGenerationLoadingPanel } from "@/components/course-planner/CourseGenerationLoadingPanel"; import { CoursePlaceInfoPanel } from "@/components/course-planner/CoursePlaceInfoPanel"; import { CoursePlannerBottomSheet } from "@/components/course-planner/CoursePlannerBottomSheet"; -import { CoursePlannerPanel } from "@/components/course-planner/CoursePlannerPanel"; +import { + CoursePlannerPanel, + type CourseCategoryOrder, + type PlaceTypeId, +} from "@/components/course-planner/CoursePlannerPanel"; import { CourseResultPanel } from "@/components/course-planner/CourseResultPanel"; import { DateTimeSelectionScreen } from "@/components/course-planner/DateTimeSelectionScreen"; import { RegionSelectionPanel } from "@/components/course-planner/RegionSelectionPanel"; @@ -14,8 +18,6 @@ import { CourseDevMapBackground } from "@/features/course-planner/components/Cou import { COURSE_LOADING_ROOM_FALLBACK } from "@/features/course-planner/constants"; import { useCoursePlannerCourses } from "@/features/course-planner/hooks/use-course-planner-courses"; import { useCoursePlannerState } from "@/features/course-planner/hooks/use-course-planner-state"; -import { useMapSearchFilters } from "@/features/map/hooks/use-map-search-filters"; -import { usePlaceFilterViewModel } from "@/features/map/hooks/use-place-filter-view-model"; import { REGION_ALL_CODE, REGION_ALL_OPTION, @@ -27,14 +29,20 @@ import { import { useBottomNavController } from "@/hooks/use-bottom-nav-controller"; import MapHomeWithDetail from "@/pages/map/MapHomeWithDetail"; import { APP_ROUTES } from "@/shared/config/routes"; -import { SAVED_PLACE_MOCKS } from "@/shared/mocks/place-mocks"; -import { MAP_ALL_CATEGORY_FILTER_CHIP } from "@/shared/types/map-home"; import { useRoomSelectionStore } from "@/store/room-selection-store"; type CoursePlannerPageProps = { skipRoomGuard?: boolean; }; +function createOrder(category: PlaceTypeId): CourseCategoryOrder { + return { + id: Date.now() + Math.floor(Math.random() * 1000), + category, + tags: [], + }; +} + export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlannerPageProps) { const navigate = useNavigate(); const selectedRoom = useRoomSelectionStore((s) => s.selectedRoom); @@ -42,35 +50,7 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann useBottomNavController(); const { courses, defaultCourseId, getCourseStops } = useCoursePlannerCourses(); const [regionSearchKeyword, setRegionSearchKeyword] = useState(""); - - const { - filterCategories, - categories, - categoryNameByCode, - isCategoryLoading, - isCategoryError, - retryLoad, - } = usePlaceFilterViewModel(); - - const { - activeCategories, - focusedCategory, - toggleCategory, - closeTagPanel, - isTagPanelOpen, - selectedTagKeysByCategory, - selectedTagCountByCategory, - toggleTagInCategory, - resetFocusedCategoryTags, - } = useMapSearchFilters({ - places: SAVED_PLACE_MOCKS, - filterCategories, - initialFocusedCategory: "놀거리", - }); - - const resetCategorySelection = useCallback(() => { - toggleCategory(MAP_ALL_CATEGORY_FILTER_CHIP); - }, [toggleCategory]); + const [courseOrders, setCourseOrders] = useState([]); const { mode, @@ -84,7 +64,7 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann courseTitle, courseStops, dateTimeDisplayValue, - canGenerate, + canGenerate: canGenerateByRegion, setMode, setDraftDistrict, setDraftDate, @@ -95,8 +75,8 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann handleConfirmDateTime, handleSelectCity, handleConfirmRegion, - handleResetPlanner, - handleGenerateCourse, + handleResetPlanner: resetPlannerBase, + handleGenerateCourse: generateCourseBase, handleSelectCourse, handleBackToCourseResults, handleSaveCourse, @@ -104,8 +84,8 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann courses, defaultCourseId, getCourseStops, - closeTagPanel, - resetCategorySelection, + closeTagPanel: () => undefined, + resetCategorySelection: () => undefined, showToast, }); const sidosQuery = useSidosQuery(); @@ -194,50 +174,68 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann handleConfirmRegion(); }, [handleConfirmRegion]); - const handleRetryLoadCategories = useCallback(() => { - void retryLoad(); - }, [retryLoad]); - const handleDismissCourse = useCallback(() => { navigate(APP_ROUTES.map); }, [navigate]); - const placeFilterBarProps = useMemo( - () => ({ - categories, - categoryNameByCode, - filterCategories, - isCategoryLoading, - isCategoryError, - onRetryLoadCategories: handleRetryLoadCategories, - activeCategories, - focusedCategory, - onToggleCategory: toggleCategory, - isTagPanelOpen, - selectedTagKeysByCategory, - selectedTagCountByCategory, - onToggleTagInCategory: toggleTagInCategory, - onResetFocusedCategoryTags: resetFocusedCategoryTags, - onCloseTagPanel: closeTagPanel, - }), - [ - activeCategories, - categories, - categoryNameByCode, - closeTagPanel, - filterCategories, - focusedCategory, - handleRetryLoadCategories, - isCategoryError, - isCategoryLoading, - isTagPanelOpen, - resetFocusedCategoryTags, - selectedTagCountByCategory, - selectedTagKeysByCategory, - toggleCategory, - toggleTagInCategory, - ], - ); + const handleCreateFirstOrder = useCallback((placeTypeId: PlaceTypeId) => { + setCourseOrders([createOrder(placeTypeId)]); + }, []); + + const handleAddOrder = useCallback(() => { + setCourseOrders((current) => [...current, createOrder("restaurant")]); + }, []); + + const handleRemoveOrder = useCallback((orderId: number) => { + setCourseOrders((current) => current.filter((order) => order.id !== orderId)); + }, []); + + const handleSelectOrderCategory = useCallback((orderId: number, placeTypeId: PlaceTypeId) => { + setCourseOrders((current) => + current.map((order) => + order.id === orderId + ? { + ...order, + category: placeTypeId, + tags: [], + } + : order, + ), + ); + }, []); + + const handleToggleOrderTag = useCallback((orderId: number, tag: string) => { + setCourseOrders((current) => + current.map((order) => { + if (order.id !== orderId) { + return order; + } + + const hasTag = order.tags.includes(tag); + return { + ...order, + tags: hasTag ? order.tags.filter((item) => item !== tag) : [...order.tags, tag], + }; + }), + ); + }, []); + + const handleResetPlanner = useCallback(() => { + setCourseOrders([]); + resetPlannerBase(); + }, [resetPlannerBase]); + + const canGenerate = + canGenerateByRegion && + courseOrders.length > 0 && + courseOrders.every((order) => order.tags.length > 0); + + const handleGenerateCourse = useCallback(() => { + if (!canGenerate) { + return; + } + generateCourseBase(); + }, [canGenerate, generateCourseBase]); if (!skipRoomGuard && !selectedRoom) { return ; @@ -270,9 +268,9 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann cityErrorMessage={sidosQuery.isError ? "지역 정보를 불러오지 못했어요." : null} districtErrorMessage={ isAllSigunguSearchError - ? "시/군/구 정보를 확인할 수 없어요." + ? "시군구 정보를 확인할 수 없어요." : !isRegionSearching && draftSidoOption && sigungusQuery.isError - ? "시/군/구 정보를 확인할 수 없어요." + ? "시군구 정보를 확인할 수 없어요." : null } searchKeyword={regionSearchKeyword} @@ -301,10 +299,15 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann setMode("region")} onOpenDateTimeSelect={handleOpenDateTimeSelect} + onCreateFirstOrder={handleCreateFirstOrder} + onAddOrder={handleAddOrder} + onRemoveOrder={handleRemoveOrder} + onSelectOrderCategory={handleSelectOrderCategory} + onToggleOrderTag={handleToggleOrderTag} onGenerate={handleGenerateCourse} onReset={handleResetPlanner} /> @@ -335,4 +338,4 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann ); -} +} \ No newline at end of file From 6a1311b98f3e278374a1f56e7e0235bed72252fe Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Wed, 27 May 2026 14:22:48 +0900 Subject: [PATCH 2/4] fix: npm run format and lint fix --- src/components/course-planner/CoursePlannerPanel.tsx | 6 +++--- src/components/course-planner/PlaceTypeChip.tsx | 11 ++++++----- src/pages/tabs/CoursePlannerPage.tsx | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/components/course-planner/CoursePlannerPanel.tsx b/src/components/course-planner/CoursePlannerPanel.tsx index ab7206b..18d953d 100644 --- a/src/components/course-planner/CoursePlannerPanel.tsx +++ b/src/components/course-planner/CoursePlannerPanel.tsx @@ -1,5 +1,5 @@ -import type { ReactNode } from "react"; import { CalendarDays, Coffee, Flag, Plus, Trash2, Utensils } from "lucide-react"; +import type { ReactNode } from "react"; import { CoursePlannerActions } from "@/components/course-planner/CoursePlannerActions"; import { CoursePlannerField } from "@/components/course-planner/CoursePlannerField"; @@ -138,7 +138,7 @@ export function CoursePlannerPanel({ - ) : null} +

+ 코스 순서대로 장소 유형과 세부 조건을 선택해 주세요. +

- {courseOrders.length === 0 ? ( -
- {placeTypes.map((type) => ( - onCreateFirstOrder(type.id)} - /> - ))} + {isCategoryLoading ? ( + + ) : isCategoryError && categoryOptions.length === 0 ? ( +
+

카테고리를 불러오지 못했어요.

+ {onRetryLoadCategories ? ( + + ) : null}
) : (
- {courseOrders.map((order, index) => ( -
-
- - {index + 1} - - - -
- -
- {placeTypes.map((type) => ( - onSelectOrderCategory(order.id, type.id)} - /> - ))} -
- -
- {categoryTags[order.category].map((tag) => { - const selected = order.tags.includes(tag); - return ( + {courseOrders.map((order, index) => { + const selectedCategory = categoryOptions.find((type) => type.id === order.category); + + return ( +
+
+
+ + {index + 1} + +
+

+ {index + 1}번째로 갈 장소 +

+

+ 이 순서에 넣을 장소 유형을 정해 주세요. +

+
+
+ + {courseOrders.length > 1 ? ( - ); - })} + ) : null} +
+ +
+ + categoryOptions.find((option) => option.id === category)?.label ?? + category + } + isHighlighted={(category) => order.category === category} + isPanelFocused={() => false} + getSelectedTagCount={(category) => + order.category === category ? order.tags.length : 0 + } + onToggleCategory={(category) => onSelectOrderCategory(order.id, category)} + /> +
+ +
+ {selectedCategory && selectedCategory.tagGroups.length > 0 ? ( + selectedCategory.tagGroups.map((group) => { + if (isEmptyGroup(group)) { + return null; + } + + return ( +
+ {!isDefaultGroup(group) ? ( +
+ {group.name} +
+ ) : null} + onToggleOrderTag(order.id, tag)} + /> +
+ ); + }) + ) : ( +

+ 선택할 수 있는 세부 태그가 없어요. +

+ )} +
-
- ))} + ); + })}
)} + + {showAddButton ? ( + + ) : null}
- -
); } diff --git a/src/components/course-planner/CourseResultPanel.tsx b/src/components/course-planner/CourseResultPanel.tsx index bafb9df..f461c19 100644 --- a/src/components/course-planner/CourseResultPanel.tsx +++ b/src/components/course-planner/CourseResultPanel.tsx @@ -8,16 +8,18 @@ export type { CourseOption }; type CourseResultPanelProps = { courses: CourseOption[]; selectedCourseId: string; + className?: string; onSelectCourse: (courseId: string) => void; }; export function CourseResultPanel({ courses, selectedCourseId, + className, onSelectCourse, }: CourseResultPanelProps) { return ( -
+

맞춤 데이트 코스 확인하기

diff --git a/src/components/course-planner/DateTimeSelectionScreen.tsx b/src/components/course-planner/DateTimeSelectionScreen.tsx index 5f54c96..f1e9a14 100644 --- a/src/components/course-planner/DateTimeSelectionScreen.tsx +++ b/src/components/course-planner/DateTimeSelectionScreen.tsx @@ -14,6 +14,7 @@ type DateTimeSelectionScreenProps = { selectedDate: string | null; selectedStartTime: string | null; selectedEndTime: string | null; + className?: string; onSelectDate: (date: string) => void; onSelectStartTime: (time: string | null) => void; onSelectEndTime: (time: string | null) => void; @@ -31,6 +32,7 @@ export function DateTimeSelectionScreen({ selectedDate, selectedStartTime, selectedEndTime, + className, onSelectDate, onSelectStartTime, onSelectEndTime, @@ -61,7 +63,7 @@ export function DateTimeSelectionScreen({ const headerTitle = step === "date" ? "날짜 선택" : "시간 설정"; return ( -
+
{step === "time" ? (
); } From f73aacc4ee2915a002a9934ec0db47efd20abbbf Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Wed, 27 May 2026 16:10:52 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20UX=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/course-planner/CoursePlannerPanel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/course-planner/CoursePlannerPanel.tsx b/src/components/course-planner/CoursePlannerPanel.tsx index 9f264d5..ce4a7eb 100644 --- a/src/components/course-planner/CoursePlannerPanel.tsx +++ b/src/components/course-planner/CoursePlannerPanel.tsx @@ -94,11 +94,11 @@ export function CoursePlannerPanel({
- 방문할 장소 유형 고르기 + 어떤 장소를 넣을까요? *

- 코스 순서대로 장소 유형과 세부 조건을 선택해 주세요. + 코스에 넣고 싶은 장소를 순서대로 골라주세요.

@@ -141,7 +141,7 @@ export function CoursePlannerPanel({ {index + 1}번째로 갈 장소

- 이 순서에 넣을 장소 유형을 정해 주세요. + 이 순서에 넣을 장소 유형을 골라주세요.