From 0f4149c69bba22a6ca6a1191b8162e55f7b7cf2d Mon Sep 17 00:00:00 2001 From: yuji1202 Date: Thu, 30 Apr 2026 11:27:28 +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=20UI=20=EA=B5=AC=ED=98=84?= 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 | 1 + .../course-planner/CourseEditPanel.tsx | 200 +++++++++++++++ .../CourseGenerationLoadingPanel.tsx | 18 ++ .../course-planner/CoursePlaceInfoPanel.tsx | 98 +++++++ .../course-planner/CoursePlannerActions.tsx | 36 +++ .../course-planner/CoursePlannerField.tsx | 52 ++++ .../CoursePlannerMapPreview.tsx | 91 +++++++ .../course-planner/CoursePlannerPanel.tsx | 79 ++++++ .../course-planner/CourseResultPanel.tsx | 53 ++++ .../course-planner/DateTimeSelectionPanel.tsx | 216 ++++++++++++++++ .../course-planner/PlaceTypeChip.tsx | 29 +++ .../course-planner/RegionSelectionPanel.tsx | 123 +++++++++ src/pages/tabs/CoursePlannerPage.tsx | 241 +++++++++++++++++- 13 files changed, 1232 insertions(+), 5 deletions(-) create mode 100644 src/components/course-planner/CourseEditPanel.tsx create mode 100644 src/components/course-planner/CourseGenerationLoadingPanel.tsx create mode 100644 src/components/course-planner/CoursePlaceInfoPanel.tsx create mode 100644 src/components/course-planner/CoursePlannerActions.tsx create mode 100644 src/components/course-planner/CoursePlannerField.tsx create mode 100644 src/components/course-planner/CoursePlannerMapPreview.tsx create mode 100644 src/components/course-planner/CoursePlannerPanel.tsx create mode 100644 src/components/course-planner/CourseResultPanel.tsx create mode 100644 src/components/course-planner/DateTimeSelectionPanel.tsx create mode 100644 src/components/course-planner/PlaceTypeChip.tsx create mode 100644 src/components/course-planner/RegionSelectionPanel.tsx diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index c9c6aa6..029386f 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -30,6 +30,7 @@ export const router = createBrowserRouter([ { path: "dev/click_place", element: }, { path: "dev/SelectOption", element: }, { path: "login", element: }, + { path: "dev/course", element: }, { path: "app", element: }, { path: "auth/callback", diff --git a/src/components/course-planner/CourseEditPanel.tsx b/src/components/course-planner/CourseEditPanel.tsx new file mode 100644 index 0000000..7854b3f --- /dev/null +++ b/src/components/course-planner/CourseEditPanel.tsx @@ -0,0 +1,200 @@ +import { ArrowDown, ArrowUp, ChevronLeft, Trash2 } from "lucide-react"; +import { useState } from "react"; + +import type { CourseStop } from "@/components/course-planner/CoursePlaceInfoPanel"; +import { cn } from "@/lib/utils"; + +type CourseEditPanelProps = { + title: string; + stops: CourseStop[]; + onBack: () => void; + onSave: (nextTitle: string, nextStops: CourseStop[]) => void; +}; + +export function CourseEditPanel({ title, stops, onBack, onSave }: CourseEditPanelProps) { + const [draftTitle, setDraftTitle] = useState(title); + const [draftStops, setDraftStops] = useState(stops); + const [deleteTargetId, setDeleteTargetId] = useState(null); + const [isSaveConfirmOpen, setIsSaveConfirmOpen] = useState(false); + + const deleteTarget = draftStops.find((stop) => stop.id === deleteTargetId); + + const moveStop = (fromIndex: number, direction: "up" | "down") => { + const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1; + if (toIndex < 0 || toIndex >= draftStops.length) return; + + const nextStops = [...draftStops]; + const [movedStop] = nextStops.splice(fromIndex, 1); + nextStops.splice(toIndex, 0, movedStop); + setDraftStops(nextStops); + }; + + const handleDeleteStop = () => { + if (!deleteTargetId) return; + setDraftStops((current) => current.filter((stop) => stop.id !== deleteTargetId)); + setDeleteTargetId(null); + }; + + const handleConfirmSave = () => { + onSave(draftTitle.trim() || title, draftStops); + }; + + return ( +
+
+ +
+ +

코스 편집

+
+ + + +
+
+

장소 순서

+ 위/아래로 순서를 바꿀 수 있어요 +
+ + {draftStops.map((stop, index) => ( +
+ + {index + 1} + + +
+

{stop.name}

+

{stop.address}

+
+ +
+ + + +
+
+ ))} +
+ + + + {deleteTarget ? ( + setDeleteTargetId(null)} + onConfirm={handleDeleteStop} + /> + ) : null} + + {isSaveConfirmOpen ? ( + setIsSaveConfirmOpen(false)} + onConfirm={handleConfirmSave} + /> + ) : null} +
+ ); +} + +type ConfirmDialogProps = { + title: string; + description: string; + confirmLabel: string; + confirmClassName: string; + onCancel: () => void; + onConfirm: () => void; +}; + +function ConfirmDialog({ + title, + description, + confirmLabel, + confirmClassName, + onCancel, + onConfirm, +}: ConfirmDialogProps) { + return ( +
+
+
+

{title}

+

{description}

+
+
+ + +
+
+
+ ); +} diff --git a/src/components/course-planner/CourseGenerationLoadingPanel.tsx b/src/components/course-planner/CourseGenerationLoadingPanel.tsx new file mode 100644 index 0000000..66d42a7 --- /dev/null +++ b/src/components/course-planner/CourseGenerationLoadingPanel.tsx @@ -0,0 +1,18 @@ +import { Loader2 } from "lucide-react"; + +export function CourseGenerationLoadingPanel() { + return ( +
+
+ +
+

+ 실심 한 두줄 방을 위한 +
+ 맞춤 데이트코스를 생성하고 있어요 +

+ +
+
+ ); +} diff --git a/src/components/course-planner/CoursePlaceInfoPanel.tsx b/src/components/course-planner/CoursePlaceInfoPanel.tsx new file mode 100644 index 0000000..4b726ef --- /dev/null +++ b/src/components/course-planner/CoursePlaceInfoPanel.tsx @@ -0,0 +1,98 @@ +import { ChevronDown, ChevronLeft, MapPin, Pencil, PersonStanding } from "lucide-react"; + +export type CourseStop = { + id: string; + name: string; + address: string; + category: string; + walkingTime: string; + hours: string; +}; + +type CoursePlaceInfoPanelProps = { + courseTitle: string; + stops: CourseStop[]; + onBack: () => void; + onEdit: () => void; +}; + +export function CoursePlaceInfoPanel({ courseTitle, stops, onBack, onEdit }: CoursePlaceInfoPanelProps) { + const primaryStop = stops[0]; + + return ( +
+
+ +
+ +

{courseTitle}

+ +
+ +
+
+ + + +
+ +
+ {stops.map((stop) => ( +
+

{stop.name}

+

{stop.address}

+ + +
+ + {stop.walkingTime} +
+
+ ))} +
+
+ + {primaryStop ? ( +
+

{primaryStop.name}

+

{primaryStop.address}

+ + + +
+ {primaryStop.category} + + {primaryStop.hours} + + +
+
+ ) : null} +
+ ); +} diff --git a/src/components/course-planner/CoursePlannerActions.tsx b/src/components/course-planner/CoursePlannerActions.tsx new file mode 100644 index 0000000..b1ec47a --- /dev/null +++ b/src/components/course-planner/CoursePlannerActions.tsx @@ -0,0 +1,36 @@ +import { RotateCcw } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +type CoursePlannerActionsProps = { + canGenerate: boolean; + onGenerate: () => void; + onReset: () => void; +}; + +export function CoursePlannerActions({ canGenerate, onGenerate, onReset }: CoursePlannerActionsProps) { + return ( +
+ + + +
+ ); +} diff --git a/src/components/course-planner/CoursePlannerField.tsx b/src/components/course-planner/CoursePlannerField.tsx new file mode 100644 index 0000000..d47fc0e --- /dev/null +++ b/src/components/course-planner/CoursePlannerField.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +type CoursePlannerFieldProps = { + label: string; + value: string; + placeholder?: string; + required?: boolean; + icon?: ReactNode; + className?: string; + onClick?: () => void; +}; + +export function CoursePlannerField({ + label, + value, + placeholder, + required = false, + icon, + className, + onClick, +}: CoursePlannerFieldProps) { + const hasValue = value.trim().length > 0; + + return ( +
+ + {label} + {required ? * : null} + + + +
+ ); +} diff --git a/src/components/course-planner/CoursePlannerMapPreview.tsx b/src/components/course-planner/CoursePlannerMapPreview.tsx new file mode 100644 index 0000000..946fe8b --- /dev/null +++ b/src/components/course-planner/CoursePlannerMapPreview.tsx @@ -0,0 +1,91 @@ +import { MapPin, Search } from "lucide-react"; + +const categoryLabels = ["맛집", "카페", "놀거리", "기타"]; + +type CoursePlannerMapPreviewProps = { + statusMessage?: string; + onMapClick?: () => void; +}; + +export function CoursePlannerMapPreview({ statusMessage, onMapClick }: CoursePlannerMapPreviewProps) { + return ( +
+ + + {statusMessage ? ( +
+ {statusMessage} +
+ ) : null} + +
+
+ + 실한 두줄 지도 +
+
+ +
+
+ + 저장해둔 장소를 검색해 보세요 + + +
+ +
+ {categoryLabels.map((label, index) => ( + + {index === 0 ? "🍴 " : index === 1 ? "☕ " : index === 2 ? "🚩 " : "+ "} + {label} + + ))} +
+
+
+ ); +} + +type MapMarkerProps = { + className: string; + label: string; +}; + +function MapMarker({ className, label }: MapMarkerProps) { + return ( + + + {label} + + + + ); +} diff --git a/src/components/course-planner/CoursePlannerPanel.tsx b/src/components/course-planner/CoursePlannerPanel.tsx new file mode 100644 index 0000000..fa8b289 --- /dev/null +++ b/src/components/course-planner/CoursePlannerPanel.tsx @@ -0,0 +1,79 @@ +import { CalendarDays, Coffee, Flag, MoreHorizontal, Utensils } from "lucide-react"; + +import { CoursePlannerActions } from "@/components/course-planner/CoursePlannerActions"; +import { CoursePlannerField } from "@/components/course-planner/CoursePlannerField"; +import { PlaceTypeChip } from "@/components/course-planner/PlaceTypeChip"; + +export type PlaceTypeId = "restaurant" | "cafe" | "activity" | "etc"; + +type CoursePlannerPanelProps = { + regionValue: string; + dateTimeValue: string; + selectedPlaceTypeIds: PlaceTypeId[]; + canGenerate: boolean; + onOpenRegionSelect: () => void; + onOpenDateTimeSelect: () => void; + onTogglePlaceType: (placeTypeId: PlaceTypeId) => void; + onGenerate: () => void; + onReset: () => void; +}; + +const placeTypes: Array<{ id: PlaceTypeId; label: string; icon: React.ReactNode }> = [ + { id: "restaurant", label: "맛집", icon: }, + { id: "cafe", label: "카페", icon: }, + { id: "activity", label: "놀거리", icon: }, + { id: "etc", label: "기타", icon: }, +]; + +export function CoursePlannerPanel({ + regionValue, + dateTimeValue, + selectedPlaceTypeIds, + canGenerate, + onOpenRegionSelect, + onOpenDateTimeSelect, + onTogglePlaceType, + onGenerate, + onReset, +}: CoursePlannerPanelProps) { + return ( +
+

맞춤 데이트코스 설정하기

+ +
+ + + } + onClick={onOpenDateTimeSelect} + /> + +
+ 장소 종류 +
+ {placeTypes.map((type) => ( + onTogglePlaceType(type.id)} + /> + ))} +
+
+
+ + +
+ ); +} diff --git a/src/components/course-planner/CourseResultPanel.tsx b/src/components/course-planner/CourseResultPanel.tsx new file mode 100644 index 0000000..2613ee5 --- /dev/null +++ b/src/components/course-planner/CourseResultPanel.tsx @@ -0,0 +1,53 @@ +import { ChevronRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export type CourseOption = { + id: string; + title: string; + description: string; +}; + +type CourseResultPanelProps = { + courses: CourseOption[]; + selectedCourseId: string; + onSelectCourse: (courseId: string) => void; +}; + +export function CourseResultPanel({ courses, selectedCourseId, onSelectCourse }: CourseResultPanelProps) { + return ( +
+
+ +

맞춤 데이트코스 확인하기

+

+ 마음에 드는 코스를 선택해서 장소 정보를 확인해보세요. +

+ +
+ {courses.map((course) => { + const selected = course.id === selectedCourseId; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/course-planner/DateTimeSelectionPanel.tsx b/src/components/course-planner/DateTimeSelectionPanel.tsx new file mode 100644 index 0000000..d0859f6 --- /dev/null +++ b/src/components/course-planner/DateTimeSelectionPanel.tsx @@ -0,0 +1,216 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export type DateTimeSelection = { + date: string; + weekday: string; + startTime: string | null; + endTime: string | null; +}; + +type DateTimeSelectionPanelProps = { + selectedDate: string; + selectedStartTime: string | null; + selectedEndTime: string | null; + onSelectDate: (date: string) => void; + onSelectStartTime: (time: string | null) => void; + onSelectEndTime: (time: string | null) => void; + onClose: () => void; + onConfirm: () => void; +}; + +const weekdayLabels = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; +const weekdaysKo = ["\uc77c", "\uc6d4", "\ud654", "\uc218", "\ubaa9", "\uae08", "\ud1a0"]; +const monthLabel = "April 2026"; +const monthStartBlankCount = 3; +const dateOptions = Array.from({ length: 30 }, (_, index) => { + const day = index + 1; + const date = `2026.04.${String(day).padStart(2, "0")}`; + const weekday = weekdaysKo[new Date(2026, 3, day).getDay()]; + return { date, day, weekday }; +}); +const startTimeOptions = ["11:00", "12:00", "13:00", "14:00", "15:00"]; +const endTimeOptions = ["18:00", "19:00", "20:00", "21:00"]; + +export function getDateTimeDisplayValue(selection: DateTimeSelection | null) { + if (!selection) return ""; + if (!selection.startTime || !selection.endTime) { + return `${selection.date} ${selection.weekday}\uc694\uc77c`; + } + return `${selection.date} ${selection.weekday}\uc694\uc77c ${selection.startTime} ~ ${selection.endTime}`; +} + +export function DateTimeSelectionPanel({ + selectedDate, + selectedStartTime, + selectedEndTime, + onSelectDate, + onSelectStartTime, + onSelectEndTime, + onClose, + onConfirm, +}: DateTimeSelectionPanelProps) { + const selectedDateOption = dateOptions.find((option) => option.date === selectedDate) ?? dateOptions[19]; + const hasTimeRange = selectedStartTime != null && selectedEndTime != null; + const confirmLabel = hasTimeRange + ? `${selectedDateOption.date} ${selectedStartTime} ~ ${selectedEndTime} \uc124\uc815\ud558\uae30` + : `${selectedDateOption.date} \uc124\uc815\ud558\uae30`; + + return ( +
+
+
+ + +
+ {monthLabel} + +
+ +
+ + +
+
+ +
+
+ {weekdayLabels.map((weekday) => ( + + {weekday} + + ))} +
+ +
+ {Array.from({ length: monthStartBlankCount }).map((_, index) => ( + + ))} + {dateOptions.map((option) => { + const selected = option.date === selectedDateOption.date; + return ( + + ); + })} +
+
+ +
+
+ Time + +
+ +
+ { + onSelectStartTime(time); + if (!selectedEndTime || time >= selectedEndTime) { + onSelectEndTime("21:00"); + } + }} + /> + { + if (selectedStartTime && time <= selectedStartTime) return; + onSelectEndTime(time); + }} + /> +
+
+ +
+ +
+
+
+ ); +} + +type TimeChipRowProps = { + label: string; + selectedTime: string | null; + options: string[]; + onSelect: (time: string) => void; +}; + +function TimeChipRow({ label, selectedTime, options, onSelect }: TimeChipRowProps) { + return ( +
+ {label} +
+ {options.map((time) => { + const selected = time === selectedTime; + return ( + + ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/course-planner/PlaceTypeChip.tsx b/src/components/course-planner/PlaceTypeChip.tsx new file mode 100644 index 0000000..173684a --- /dev/null +++ b/src/components/course-planner/PlaceTypeChip.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +type PlaceTypeChipProps = { + label: string; + icon: ReactNode; + selected?: boolean; + onClick?: () => void; +}; + +export function PlaceTypeChip({ label, icon, selected = false, onClick }: PlaceTypeChipProps) { + return ( + + ); +} diff --git a/src/components/course-planner/RegionSelectionPanel.tsx b/src/components/course-planner/RegionSelectionPanel.tsx new file mode 100644 index 0000000..a3e9a50 --- /dev/null +++ b/src/components/course-planner/RegionSelectionPanel.tsx @@ -0,0 +1,123 @@ +import { Search, X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +type RegionSelectionPanelProps = { + selectedCity: string; + selectedDistrict: string; + onSelectCity: (city: string) => void; + onSelectDistrict: (district: string) => void; + onClose: () => void; + onConfirm: () => void; +}; + +const cities = ["서울", "경기", "인천", "부산", "대구", "대전"]; +const districtsByCity: Record = { + 서울: ["전체", "강남구", "강동구", "강북구", "강서구", "관악구"], + 경기: ["전체", "성남시", "수원시", "고양시", "용인시", "하남시"], + 인천: ["전체", "남동구", "연수구", "부평구", "서구", "중구"], + 부산: ["전체", "해운대구", "수영구", "부산진구", "동래구", "남구"], + 대구: ["전체", "중구", "동구", "서구", "수성구", "달서구"], + 대전: ["전체", "서구", "유성구", "중구", "동구", "대덕구"], +}; + +export function RegionSelectionPanel({ + selectedCity, + selectedDistrict, + onSelectCity, + onSelectDistrict, + onClose, + onConfirm, +}: RegionSelectionPanelProps) { + const districts = districtsByCity[selectedCity] ?? districtsByCity["서울"]; + const confirmLabel = selectedDistrict === "전체" + ? `${selectedCity} 전체 설정하기` + : `${selectedCity} ${selectedDistrict} 설정하기`; + + return ( +
+
+ +
+

지역설정

+ +
+ + + +
+
+
+ 시/도 +
+
+ {cities.map((city) => { + const selected = city === selectedCity; + return ( + + ); + })} +
+
+ +
+
+ 시/구/군 +
+
+ {districts.map((district) => { + const selected = district === selectedDistrict; + return ( + + ); + })} +
+
+
+ + +
+ ); +} diff --git a/src/pages/tabs/CoursePlannerPage.tsx b/src/pages/tabs/CoursePlannerPage.tsx index f7d8b8d..7e8bfcd 100644 --- a/src/pages/tabs/CoursePlannerPage.tsx +++ b/src/pages/tabs/CoursePlannerPage.tsx @@ -1,23 +1,254 @@ +import { useEffect, useState } from "react"; import { Navigate } from "react-router-dom"; import { BottomNavigationBar } from "@/components/common/BottomNavigationBar"; import { BottomNavToast } from "@/components/common/BottomNavToast"; +import { CourseEditPanel } from "@/components/course-planner/CourseEditPanel"; +import { CourseGenerationLoadingPanel } from "@/components/course-planner/CourseGenerationLoadingPanel"; +import { CoursePlaceInfoPanel, type CourseStop } from "@/components/course-planner/CoursePlaceInfoPanel"; +import { CoursePlannerMapPreview } from "@/components/course-planner/CoursePlannerMapPreview"; +import { CoursePlannerPanel, type PlaceTypeId } from "@/components/course-planner/CoursePlannerPanel"; +import { CourseResultPanel, type CourseOption } from "@/components/course-planner/CourseResultPanel"; +import { + DateTimeSelectionPanel, + getDateTimeDisplayValue, + type DateTimeSelection, +} from "@/components/course-planner/DateTimeSelectionPanel"; +import { RegionSelectionPanel } from "@/components/course-planner/RegionSelectionPanel"; import { useBottomNavController } from "@/hooks/use-bottom-nav-controller"; import { useRoomSelectionStore } from "@/store/room-selection-store"; -export default function CoursePlannerPage() { +type CoursePlannerMode = "form" | "region" | "datetime" | "loading" | "result" | "detail" | "edit"; + +const mockCourses: CourseOption[] = [ + { id: "course-1", title: "코스 1", description: "캠퍼스 산책부터 감동까지" }, + { id: "course-2", title: "코스 2", description: "카페와 전시를 가볍게" }, + { id: "course-3", title: "코스 3", description: "저녁까지 이어지는 여유 코스" }, +]; + +const mockStops: CourseStop[] = [ + { + id: "hufs-seoul", + name: "한국외국어대학교 서울캠퍼스", + address: "서울 동대문구 이문로 107", + category: "대학 · 캠퍼스", + walkingTime: "도보 10분", + hours: "10:40 ~ 19:30", + }, + { + id: "gangneung", + name: "감동", + address: "회기로 25길 10-13 1층", + category: "맛집", + walkingTime: "도보 8분", + hours: "11:30 ~ 21:00", + }, + { + id: "oegwan-street", + name: "사르스트 외대점", + address: "회기로 23길 2", + category: "카페", + walkingTime: "도보 7분", + hours: "12:00 ~ 22:00", + }, +]; + +type CoursePlannerPageProps = { + skipRoomGuard?: boolean; +}; + +export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlannerPageProps) { const selectedRoom = useRoomSelectionStore((s) => s.selectedRoom); const { toastMessage, handleSelectBottomNav } = useBottomNavController(); + const [mode, setMode] = useState("form"); + const [regionValue, setRegionValue] = useState(""); + const [draftCity, setDraftCity] = useState("서울"); + const [draftDistrict, setDraftDistrict] = useState("강남구"); + const [dateTimeValue, setDateTimeValue] = useState(null); + const [draftDate, setDraftDate] = useState("2026.04.20"); + const [draftStartTime, setDraftStartTime] = useState("13:00"); + const [draftEndTime, setDraftEndTime] = useState("21:00"); + const [selectedPlaceTypeIds, setSelectedPlaceTypeIds] = useState(["restaurant"]); + const [selectedCourseId, setSelectedCourseId] = useState(mockCourses[0]?.id ?? ""); + const [courseTitle, setCourseTitle] = useState(mockCourses[0]?.title ?? "코스 1"); + const [courseStops, setCourseStops] = useState(mockStops); + const [completionNoticeVisible, setCompletionNoticeVisible] = useState(false); + + useEffect(() => { + if (mode !== "loading") return; + + const timerId = window.setTimeout(() => { + setCompletionNoticeVisible(true); + setMode("result"); + }, 900); + + return () => window.clearTimeout(timerId); + }, [mode]); + + useEffect(() => { + if (!completionNoticeVisible) return; - if (!selectedRoom) { + const timerId = window.setTimeout(() => { + setCompletionNoticeVisible(false); + }, 5000); + + return () => window.clearTimeout(timerId); + }, [completionNoticeVisible]); + + if (!skipRoomGuard && !selectedRoom) { return ; } + const canGenerate = regionValue.trim().length > 0 && selectedPlaceTypeIds.length > 0; + + const handleSelectCity = (city: string) => { + setDraftCity(city); + setDraftDistrict("전체"); + }; + + const handleConfirmRegion = () => { + setRegionValue(draftDistrict === "전체" ? draftCity : `${draftCity} ${draftDistrict}`); + setMode("form"); + }; + + const handleConfirmDateTime = () => { + const weekday = new Date(draftDate.replaceAll(".", "/")) + .toLocaleDateString("ko-KR", { weekday: "short" }) + .replace("요일", ""); + + setDateTimeValue({ + date: draftDate, + weekday, + startTime: draftStartTime, + endTime: draftEndTime, + }); + setMode("form"); + }; + + const handleTogglePlaceType = (placeTypeId: PlaceTypeId) => { + setSelectedPlaceTypeIds((current) => { + if (current.includes(placeTypeId)) { + return current.filter((id) => id !== placeTypeId); + } + return [...current, placeTypeId]; + }); + }; + + const handleResetPlanner = () => { + setRegionValue(""); + setDraftCity("서울"); + setDraftDistrict("강남구"); + setDateTimeValue(null); + setDraftDate("2026.04.20"); + setDraftStartTime("13:00"); + setDraftEndTime("21:00"); + setSelectedPlaceTypeIds(["restaurant"]); + setCompletionNoticeVisible(false); + setMode("form"); + }; + + const handleGenerateCourse = () => { + if (!canGenerate) return; + setMode("loading"); + }; + + const handleSelectCourse = (courseId: string) => { + const selectedCourse = mockCourses.find((course) => course.id === courseId); + setSelectedCourseId(courseId); + setCourseTitle(selectedCourse?.title ?? "코스 1"); + setCourseStops(mockStops); + setMode("detail"); + }; + + const handleSaveCourseEdit = (nextTitle: string, nextStops: CourseStop[]) => { + setCourseTitle(nextTitle); + setCourseStops(nextStops); + setCompletionNoticeVisible(true); + setMode("detail"); + }; + + const handleMapClick = () => { + if (mode === "detail" || mode === "edit") { + setMode("detail"); + } + }; + + const statusMessage = completionNoticeVisible ? "데이트코스가 완성되었습니다" : undefined; + return ( -
-
+
+
+ + + {mode === "region" ? ( + setMode("form")} + onConfirm={handleConfirmRegion} + /> + ) : null} + + {mode === "datetime" ? ( + setMode("form")} + onConfirm={handleConfirmDateTime} + /> + ) : null} + + {mode === "form" ? ( + setMode("region")} + onOpenDateTimeSelect={() => setMode("datetime")} + onTogglePlaceType={handleTogglePlaceType} + onGenerate={handleGenerateCourse} + onReset={handleResetPlanner} + /> + ) : null} + + {mode === "loading" ? : null} + + {mode === "result" ? ( + + ) : null} + + {mode === "detail" ? ( + setMode("result")} + onEdit={() => setMode("edit")} + /> + ) : null} + + {mode === "edit" ? ( + setMode("detail")} + onSave={handleSaveCourseEdit} + /> + ) : null} +
+
); -} +} \ No newline at end of file From c18043e8f0ebd5050032cc199a8f46cfdd5563f2 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Thu, 30 Apr 2026 17:36:52 +0900 Subject: [PATCH 2/4] fix: npm run lint:fix --- .../course-planner/CourseEditPanel.tsx | 8 +-- .../CourseGenerationLoadingPanel.tsx | 9 ++-- .../course-planner/CoursePlaceInfoPanel.tsx | 13 +++-- .../course-planner/CoursePlannerActions.tsx | 10 ++-- .../course-planner/CoursePlannerField.tsx | 9 +--- .../CoursePlannerMapPreview.tsx | 49 +++++++++++-------- .../course-planner/CoursePlannerPanel.tsx | 2 +- .../course-planner/CourseResultPanel.tsx | 10 ++-- .../course-planner/DateTimeSelectionPanel.tsx | 35 ++++++++----- .../course-planner/PlaceTypeChip.tsx | 2 +- .../course-planner/RegionSelectionPanel.tsx | 21 +++++--- src/pages/tabs/CoursePlannerPage.tsx | 19 +++++-- 12 files changed, 114 insertions(+), 73 deletions(-) diff --git a/src/components/course-planner/CourseEditPanel.tsx b/src/components/course-planner/CourseEditPanel.tsx index 7854b3f..63a056a 100644 --- a/src/components/course-planner/CourseEditPanel.tsx +++ b/src/components/course-planner/CourseEditPanel.tsx @@ -40,14 +40,14 @@ export function CourseEditPanel({ title, stops, onBack, onSave }: CourseEditPane }; return ( -
+
{statusMessage ? ( -
+
{statusMessage}
) : null} -
+
실한 두줄 지도 @@ -52,9 +61,7 @@ export function CoursePlannerMapPreview({ statusMessage, onMapClick }: CoursePla
- - 저장해둔 장소를 검색해 보세요 - + 저장해둔 장소를 검색해 보세요
diff --git a/src/components/course-planner/CoursePlannerPanel.tsx b/src/components/course-planner/CoursePlannerPanel.tsx index fa8b289..9a6ebb9 100644 --- a/src/components/course-planner/CoursePlannerPanel.tsx +++ b/src/components/course-planner/CoursePlannerPanel.tsx @@ -37,7 +37,7 @@ export function CoursePlannerPanel({ onReset, }: CoursePlannerPanelProps) { return ( -
+

맞춤 데이트코스 설정하기

diff --git a/src/components/course-planner/CourseResultPanel.tsx b/src/components/course-planner/CourseResultPanel.tsx index 2613ee5..b56ee14 100644 --- a/src/components/course-planner/CourseResultPanel.tsx +++ b/src/components/course-planner/CourseResultPanel.tsx @@ -14,9 +14,13 @@ type CourseResultPanelProps = { onSelectCourse: (courseId: string) => void; }; -export function CourseResultPanel({ courses, selectedCourseId, onSelectCourse }: CourseResultPanelProps) { +export function CourseResultPanel({ + courses, + selectedCourseId, + onSelectCourse, +}: CourseResultPanelProps) { return ( -
+

맞춤 데이트코스 확인하기

@@ -33,7 +37,7 @@ export function CourseResultPanel({ courses, selectedCourseId, onSelectCourse }: type="button" onClick={() => onSelectCourse(course.id)} className={cn( - "flex h-12 items-center justify-between rounded-lg border px-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50", + "focus-visible:ring-ring/50 flex h-12 items-center justify-between rounded-lg border px-4 text-left transition-colors focus-visible:ring-3 focus-visible:outline-none", selected ? "border-[#f06f6b] bg-[#fff0ee]" : "border-[#dedede] bg-white hover:bg-[#fafafa]", diff --git a/src/components/course-planner/DateTimeSelectionPanel.tsx b/src/components/course-planner/DateTimeSelectionPanel.tsx index d0859f6..d97827c 100644 --- a/src/components/course-planner/DateTimeSelectionPanel.tsx +++ b/src/components/course-planner/DateTimeSelectionPanel.tsx @@ -51,7 +51,8 @@ export function DateTimeSelectionPanel({ onClose, onConfirm, }: DateTimeSelectionPanelProps) { - const selectedDateOption = dateOptions.find((option) => option.date === selectedDate) ?? dateOptions[19]; + const selectedDateOption = + dateOptions.find((option) => option.date === selectedDate) ?? dateOptions[19]; const hasTimeRange = selectedStartTime != null && selectedEndTime != null; const confirmLabel = hasTimeRange ? `${selectedDateOption.date} ${selectedStartTime} ~ ${selectedEndTime} \uc124\uc815\ud558\uae30` @@ -64,7 +65,7 @@ export function DateTimeSelectionPanel({
@@ -172,7 +179,7 @@ export function DateTimeSelectionPanel({ @@ -202,8 +209,10 @@ function TimeChipRow({ label, selectedTime, options, onSelect }: TimeChipRowProp type="button" onClick={() => onSelect(time)} className={cn( - "shrink-0 rounded-md px-2 py-1 text-[0.68rem] font-medium transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50", - selected ? "bg-[#fff0ee] text-[#f06f6b]" : "bg-[#fafafa] text-[#5f5f5f] hover:bg-[#f1f1f1]", + "focus-visible:ring-ring/50 shrink-0 rounded-md px-2 py-1 text-[0.68rem] font-medium transition-colors focus-visible:ring-3 focus-visible:outline-none", + selected + ? "bg-[#fff0ee] text-[#f06f6b]" + : "bg-[#fafafa] text-[#5f5f5f] hover:bg-[#f1f1f1]", )} > {time} @@ -213,4 +222,4 @@ function TimeChipRow({ label, selectedTime, options, onSelect }: TimeChipRowProp
); -} \ No newline at end of file +} diff --git a/src/components/course-planner/PlaceTypeChip.tsx b/src/components/course-planner/PlaceTypeChip.tsx index 173684a..d350acb 100644 --- a/src/components/course-planner/PlaceTypeChip.tsx +++ b/src/components/course-planner/PlaceTypeChip.tsx @@ -16,7 +16,7 @@ export function PlaceTypeChip({ label, icon, selected = false, onClick }: PlaceT onClick={onClick} aria-pressed={selected} className={cn( - "inline-flex h-8 items-center gap-1.5 rounded-lg border px-3 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50", + "focus-visible:ring-ring/50 inline-flex h-8 items-center gap-1.5 rounded-lg border px-3 text-xs font-medium transition-colors focus-visible:ring-3 focus-visible:outline-none", selected ? "border-[#f06f6b] bg-[#fff0ee] text-[#d95f5b]" : "border-[#e5e7eb] bg-white text-[#4b5563] hover:bg-[#fafafa]", diff --git a/src/components/course-planner/RegionSelectionPanel.tsx b/src/components/course-planner/RegionSelectionPanel.tsx index a3e9a50..c5d1194 100644 --- a/src/components/course-planner/RegionSelectionPanel.tsx +++ b/src/components/course-planner/RegionSelectionPanel.tsx @@ -30,12 +30,13 @@ export function RegionSelectionPanel({ onConfirm, }: RegionSelectionPanelProps) { const districts = districtsByCity[selectedCity] ?? districtsByCity["서울"]; - const confirmLabel = selectedDistrict === "전체" - ? `${selectedCity} 전체 설정하기` - : `${selectedCity} ${selectedDistrict} 설정하기`; + const confirmLabel = + selectedDistrict === "전체" + ? `${selectedCity} 전체 설정하기` + : `${selectedCity} ${selectedDistrict} 설정하기`; return ( -
+
@@ -43,7 +44,7 @@ export function RegionSelectionPanel({ diff --git a/src/pages/tabs/CoursePlannerPage.tsx b/src/pages/tabs/CoursePlannerPage.tsx index 7e8bfcd..de69182 100644 --- a/src/pages/tabs/CoursePlannerPage.tsx +++ b/src/pages/tabs/CoursePlannerPage.tsx @@ -5,14 +5,23 @@ import { BottomNavigationBar } from "@/components/common/BottomNavigationBar"; import { BottomNavToast } from "@/components/common/BottomNavToast"; import { CourseEditPanel } from "@/components/course-planner/CourseEditPanel"; import { CourseGenerationLoadingPanel } from "@/components/course-planner/CourseGenerationLoadingPanel"; -import { CoursePlaceInfoPanel, type CourseStop } from "@/components/course-planner/CoursePlaceInfoPanel"; +import { + CoursePlaceInfoPanel, + type CourseStop, +} from "@/components/course-planner/CoursePlaceInfoPanel"; import { CoursePlannerMapPreview } from "@/components/course-planner/CoursePlannerMapPreview"; -import { CoursePlannerPanel, type PlaceTypeId } from "@/components/course-planner/CoursePlannerPanel"; -import { CourseResultPanel, type CourseOption } from "@/components/course-planner/CourseResultPanel"; import { + CoursePlannerPanel, + type PlaceTypeId, +} from "@/components/course-planner/CoursePlannerPanel"; +import { + type CourseOption, + CourseResultPanel, +} from "@/components/course-planner/CourseResultPanel"; +import { + type DateTimeSelection, DateTimeSelectionPanel, getDateTimeDisplayValue, - type DateTimeSelection, } from "@/components/course-planner/DateTimeSelectionPanel"; import { RegionSelectionPanel } from "@/components/course-planner/RegionSelectionPanel"; import { useBottomNavController } from "@/hooks/use-bottom-nav-controller"; @@ -251,4 +260,4 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann
); -} \ No newline at end of file +} From ef09dc9b32e236a32999a0713776837813480068 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Thu, 30 Apr 2026 22:38:03 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=A7=9E=EC=B6=A4=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EC=BD=94=EC=8A=A4=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20UI=20UX=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../course-planner/AmPmTimeWheelGroup.tsx | 374 +++++++++++++++++ .../course-planner/CourseConfirmModal.tsx | 65 +++ .../course-planner/CourseEditPanel.tsx | 127 ++---- .../CourseGenerationLoadingPanel.tsx | 8 +- .../course-planner/CoursePlaceInfoPanel.tsx | 34 +- .../course-planner/CoursePlaceTagSelector.tsx | 111 +++++ .../course-planner/CoursePlannerActions.tsx | 8 +- .../CoursePlannerBottomSheet.tsx | 27 ++ .../course-planner/CoursePlannerField.tsx | 15 +- .../CoursePlannerMapPreview.tsx | 95 +---- .../course-planner/CoursePlannerPanel.tsx | 40 +- .../course-planner/CourseResultPanel.tsx | 20 +- .../course-planner/DateTimeSelectionPanel.tsx | 382 +++++++++--------- .../DateTimeSelectionScreen.tsx | 130 ++++++ .../course-planner/PlaceTypeChip.tsx | 29 -- .../course-planner/RegionSelectionPanel.tsx | 53 ++- .../course-planner/course-date-time.ts | 34 ++ src/components/ui/BottomSheet.tsx | 10 +- src/components/ui/Select.tsx | 83 ++++ .../room/hooks/useOverlayFlowController.ts | 14 +- src/index.css | 16 +- src/pages/tabs/CoursePlannerPage.tsx | 222 +++++++--- 22 files changed, 1322 insertions(+), 575 deletions(-) create mode 100644 src/components/course-planner/AmPmTimeWheelGroup.tsx create mode 100644 src/components/course-planner/CourseConfirmModal.tsx create mode 100644 src/components/course-planner/CoursePlaceTagSelector.tsx create mode 100644 src/components/course-planner/CoursePlannerBottomSheet.tsx create mode 100644 src/components/course-planner/DateTimeSelectionScreen.tsx delete mode 100644 src/components/course-planner/PlaceTypeChip.tsx create mode 100644 src/components/course-planner/course-date-time.ts create mode 100644 src/components/ui/Select.tsx diff --git a/src/components/course-planner/AmPmTimeWheelGroup.tsx b/src/components/course-planner/AmPmTimeWheelGroup.tsx new file mode 100644 index 0000000..f1133ad --- /dev/null +++ b/src/components/course-planner/AmPmTimeWheelGroup.tsx @@ -0,0 +1,374 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; + +import { isHmString } from "@/components/course-planner/course-date-time"; +import { cn } from "@/lib/utils"; + +const ITEM_HEIGHT_PX = 44; +const WHEEL_HEIGHT_PX = 216; +const EDGE_PADDING_PX = (WHEEL_HEIGHT_PX - ITEM_HEIGHT_PX) / 2; + +type Period = "AM" | "PM"; +type Minute = "00" | "30"; + +type CompleteTriplet = { + period: Period; + hour12: string; + minute: Minute; +}; + +const DEFAULT_TRIPLET: CompleteTriplet = { + period: "AM", + hour12: "12", + minute: "00", +}; + +const PERIOD_ITEMS: { value: Period; label: string }[] = [ + { value: "AM", label: "오전" }, + { value: "PM", label: "오후" }, +]; + +const HOUR_ORDER = ["12", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"] as const; + +const MINUTE_ITEMS: { value: Minute; label: string }[] = [ + { value: "00", label: "00" }, + { value: "30", label: "30" }, +]; + +function hmToTriplet(hm: string): CompleteTriplet { + const [hs, ms] = hm.split(":"); + const h24 = Number(hs); + const minute: Minute = ms === "30" ? "30" : "00"; + const period: Period = h24 >= 12 ? "PM" : "AM"; + let hour12 = h24 % 12; + if (hour12 === 0) hour12 = 12; + return { period, hour12: String(hour12), minute }; +} + +function tripletToHm(t: CompleteTriplet): string { + const hour12 = Number(t.hour12); + let h24 = hour12; + if (t.period === "AM") { + if (hour12 === 12) h24 = 0; + } else if (hour12 !== 12) { + h24 = hour12 + 12; + } + + const mm = t.minute === "30" ? "30" : "00"; + return `${String(h24).padStart(2, "0")}:${mm}`; +} + +function parseTripletFromValue(value: string | null): CompleteTriplet { + if (!value || !isHmString(value)) return { ...DEFAULT_TRIPLET }; + return hmToTriplet(value); +} + +function scrollTopForIndex(index: number) { + return index * ITEM_HEIGHT_PX; +} + +type WheelColumnProps = { + ariaLabel: string; + items: readonly { value: T; label: string }[]; + selected: T; + onSelect: (next: T) => void; + disabled?: boolean; +}; + +function WheelColumn({ + ariaLabel, + items, + selected, + onSelect, + disabled, +}: WheelColumnProps) { + const scrollRef = useRef(null); + const syncingRef = useRef(false); + const settleTimerRef = useRef | null>(null); + const [centerIdx, setCenterIdx] = useState(0); + + const scrollToIndex = useCallback( + (index: number, instant: boolean) => { + const el = scrollRef.current; + if (!el) return; + const clamped = Math.min(items.length - 1, Math.max(0, index)); + const top = scrollTopForIndex(clamped); + syncingRef.current = true; + const finishSync = () => { + queueMicrotask(() => { + syncingRef.current = false; + }); + }; + + if (instant) { + el.scrollTop = top; + setCenterIdx(clamped); + requestAnimationFrame(() => { + el.scrollTop = top; + setCenterIdx(clamped); + finishSync(); + }); + return; + } + + setCenterIdx(clamped); + el.scrollTo({ top, behavior: "smooth" }); + window.setTimeout(() => { + syncingRef.current = false; + }, 280); + }, + [items.length], + ); + + useLayoutEffect(() => { + if (disabled) return; + const el = scrollRef.current; + if (!el) return; + const idx = items.findIndex((it) => Object.is(it.value, selected)); + const safeIdx = idx >= 0 ? idx : 0; + const top = scrollTopForIndex(safeIdx); + syncingRef.current = true; + el.scrollTop = top; + const rafId = requestAnimationFrame(() => { + el.scrollTop = top; + setCenterIdx(safeIdx); + queueMicrotask(() => { + syncingRef.current = false; + }); + }); + return () => cancelAnimationFrame(rafId); + }, [disabled, items, selected]); + + const commitScrollPosition = useCallback(() => { + const el = scrollRef.current; + if (!el || disabled || syncingRef.current) return; + + const raw = el.scrollTop / ITEM_HEIGHT_PX; + const idx = Math.min(items.length - 1, Math.max(0, Math.round(raw))); + const snappedTop = scrollTopForIndex(idx); + + const maxScroll = Math.max(0, el.scrollHeight - el.clientHeight); + const clampedTop = Math.min(maxScroll, Math.max(0, snappedTop)); + + if (Math.abs(el.scrollTop - clampedTop) > 0.5) { + el.scrollTop = clampedTop; + } + + setCenterIdx(idx); + const next = items[idx]?.value as T; + if (!Object.is(next, selected)) { + onSelect(next); + } + }, [disabled, items, onSelect, selected]); + + const scheduleCommit = useCallback( + (delay: number) => { + if (settleTimerRef.current) window.clearTimeout(settleTimerRef.current); + settleTimerRef.current = window.setTimeout(() => { + settleTimerRef.current = null; + commitScrollPosition(); + }, delay); + }, + [commitScrollPosition], + ); + + const handleScroll = useCallback(() => { + if (syncingRef.current) return; + const el = scrollRef.current; + if (!el || disabled) return; + + const idx = Math.min(items.length - 1, Math.max(0, Math.round(el.scrollTop / ITEM_HEIGHT_PX))); + setCenterIdx(idx); + + scheduleCommit(140); + }, [disabled, items.length, scheduleCommit]); + + const handleScrollEnd = useCallback(() => { + if (syncingRef.current) return; + scheduleCommit(80); + }, [scheduleCommit]); + + if (disabled) { + return ( +
+ ); + } + + return ( +
+
+
+ {items.map((item, index) => { + const atCenter = index === centerIdx; + const dist = Math.abs(index - centerIdx); + return ( +
{ + setCenterIdx(index); + scrollToIndex(index, false); + onSelect(item.value); + }} + className={cn( + "flex shrink-0 cursor-pointer snap-center snap-always items-center justify-center text-[15px] tracking-tight", + atCenter && "text-brand-coral font-semibold", + !atCenter && + dist === 1 && + "text-muted-foreground/55 dark:text-muted-foreground/50 font-normal", + !atCenter && + dist >= 2 && + "text-muted-foreground/38 dark:text-muted-foreground/35 font-normal", + )} + style={{ + height: ITEM_HEIGHT_PX, + scrollSnapAlign: "center", + scrollSnapStop: "always", + }} + > + {item.label} +
+ ); + })} +
+
+
+ ); +} + +type AmPmTimeWheelGroupProps = { + label: string; + value: string | null; + onChange: (next: string | null) => void; +}; + +export function AmPmTimeWheelGroup({ label, value, onChange }: AmPmTimeWheelGroupProps) { + const [triplet, setTriplet] = useState(() => parseTripletFromValue(value)); + const hasUserAdjustedRef = useRef(false); + + useEffect(() => { + const id = requestAnimationFrame(() => { + if (value !== null && isHmString(value)) { + setTriplet(hmToTriplet(value)); + return; + } + setTriplet({ ...DEFAULT_TRIPLET }); + hasUserAdjustedRef.current = false; + }); + return () => cancelAnimationFrame(id); + }, [value]); + + const hourItems = useMemo(() => HOUR_ORDER.map((h) => ({ value: h, label: h })), []); + + const tryCommit = useCallback( + (next: CompleteTriplet) => { + const hm = tripletToHm(next); + if (value === null && !hasUserAdjustedRef.current) return; + onChange(hm); + }, + [onChange, value], + ); + + const markUserAdjusted = useCallback(() => { + hasUserAdjustedRef.current = true; + }, []); + + const handlePeriod = useCallback( + (next: Period) => { + markUserAdjusted(); + setTriplet((prev) => { + const nt = { ...prev, period: next }; + tryCommit(nt); + return nt; + }); + }, + [markUserAdjusted, tryCommit], + ); + + const handleHour = useCallback( + (next: string) => { + markUserAdjusted(); + setTriplet((prev) => { + const nt = { ...prev, hour12: next }; + tryCommit(nt); + return nt; + }); + }, + [markUserAdjusted, tryCommit], + ); + + const handleMinute = useCallback( + (next: Minute) => { + markUserAdjusted(); + setTriplet((prev) => { + const nt = { ...prev, minute: next }; + tryCommit(nt); + return nt; + }); + }, + [markUserAdjusted, tryCommit], + ); + + return ( +
+ + {label} + + +
+
+
+ +
+ + ariaLabel={`${label} 오전·오후`} + items={PERIOD_ITEMS} + selected={triplet.period} + onSelect={handlePeriod} + /> + + ariaLabel={`${label} 시`} + items={hourItems} + selected={triplet.hour12} + onSelect={handleHour} + /> + + ariaLabel={`${label} 분`} + items={MINUTE_ITEMS} + selected={triplet.minute} + onSelect={handleMinute} + /> +
+
+
+ ); +} diff --git a/src/components/course-planner/CourseConfirmModal.tsx b/src/components/course-planner/CourseConfirmModal.tsx new file mode 100644 index 0000000..d9a679b --- /dev/null +++ b/src/components/course-planner/CourseConfirmModal.tsx @@ -0,0 +1,65 @@ +import { RoomModalShell } from "@/components/room/RoomModalShell"; +import { useOverlayFlowController } from "@/features/room/hooks"; +import { cn } from "@/lib/utils"; + +type CourseConfirmModalVariant = "default" | "danger"; + +type CourseConfirmModalProps = { + open: boolean; + title: string; + description: string; + confirmLabel: string; + historyStateKey: string; + variant?: CourseConfirmModalVariant; + onClose: () => void; + onConfirm: () => void; +}; + +export function CourseConfirmModal({ + open, + title, + description, + confirmLabel, + historyStateKey, + variant = "default", + onClose, + onConfirm, +}: CourseConfirmModalProps) { + const { isRendered, isVisible, requestClose } = useOverlayFlowController({ + open, + onClose, + historyStateKey, + }); + + if (!isRendered) { + return null; + } + + return ( + +
+

{title}

+

{description}

+
+
+ + +
+
+ ); +} diff --git a/src/components/course-planner/CourseEditPanel.tsx b/src/components/course-planner/CourseEditPanel.tsx index 63a056a..01aa036 100644 --- a/src/components/course-planner/CourseEditPanel.tsx +++ b/src/components/course-planner/CourseEditPanel.tsx @@ -1,6 +1,7 @@ -import { ArrowDown, ArrowUp, ChevronLeft, Trash2 } from "lucide-react"; +import { ArrowDown, ArrowUp, ChevronLeft, Trash2 } from "lucide-react"; import { useState } from "react"; +import { CourseConfirmModal } from "@/components/course-planner/CourseConfirmModal"; import type { CourseStop } from "@/components/course-planner/CoursePlaceInfoPanel"; import { cn } from "@/lib/utils"; @@ -40,49 +41,49 @@ export function CourseEditPanel({ title, stops, onBack, onSave }: CourseEditPane }; return ( -
-
- +
-

코스 편집

+

코스 편집

-
-

장소 순서

- 위/아래로 순서를 바꿀 수 있어요 +
+

장소 순서

+ + 위/아래로 순서를 바꿀 수 있어요 +
{draftStops.map((stop, index) => (
- + {index + 1}
-

{stop.name}

-

{stop.address}

+

{stop.name}

+

{stop.address}

@@ -90,7 +91,7 @@ export function CourseEditPanel({ title, stops, onBack, onSave }: CourseEditPane type="button" onClick={() => moveStop(index, "up")} disabled={index === 0} - className="inline-flex size-8 items-center justify-center rounded-full text-[#52525b] transition-colors hover:bg-[#f4f4f5] disabled:text-[#d4d4d8]" + className="text-muted-foreground hover:bg-muted/45 disabled:text-muted-foreground/30 inline-flex size-8 items-center justify-center rounded-full transition-colors" aria-label={`${stop.name} 위로 이동`} > @@ -99,7 +100,7 @@ export function CourseEditPanel({ title, stops, onBack, onSave }: CourseEditPane type="button" onClick={() => moveStop(index, "down")} disabled={index === draftStops.length - 1} - className="inline-flex size-8 items-center justify-center rounded-full text-[#52525b] transition-colors hover:bg-[#f4f4f5] disabled:text-[#d4d4d8]" + className="text-muted-foreground hover:bg-muted/45 disabled:text-muted-foreground/30 inline-flex size-8 items-center justify-center rounded-full transition-colors" aria-label={`${stop.name} 아래로 이동`} > @@ -107,7 +108,7 @@ export function CourseEditPanel({ title, stops, onBack, onSave }: CourseEditPane - {deleteTarget ? ( - setDeleteTargetId(null)} - onConfirm={handleDeleteStop} - /> - ) : null} - - {isSaveConfirmOpen ? ( - setIsSaveConfirmOpen(false)} - onConfirm={handleConfirmSave} - /> - ) : null} + setDeleteTargetId(null)} + onConfirm={handleDeleteStop} + /> + + setIsSaveConfirmOpen(false)} + onConfirm={handleConfirmSave} + />
); } - -type ConfirmDialogProps = { - title: string; - description: string; - confirmLabel: string; - confirmClassName: string; - onCancel: () => void; - onConfirm: () => void; -}; - -function ConfirmDialog({ - title, - description, - confirmLabel, - confirmClassName, - onCancel, - onConfirm, -}: ConfirmDialogProps) { - return ( -
-
-
-

{title}

-

{description}

-
-
- - -
-
-
- ); -} diff --git a/src/components/course-planner/CourseGenerationLoadingPanel.tsx b/src/components/course-planner/CourseGenerationLoadingPanel.tsx index 827760a..5a0d19a 100644 --- a/src/components/course-planner/CourseGenerationLoadingPanel.tsx +++ b/src/components/course-planner/CourseGenerationLoadingPanel.tsx @@ -2,17 +2,15 @@ export function CourseGenerationLoadingPanel() { return ( -
-
- +
-

+

실심 한 두줄 방을 위한
맞춤 데이트코스를 생성하고 있어요

diff --git a/src/components/course-planner/CoursePlaceInfoPanel.tsx b/src/components/course-planner/CoursePlaceInfoPanel.tsx index fb5d687..337cf2a 100644 --- a/src/components/course-planner/CoursePlaceInfoPanel.tsx +++ b/src/components/course-planner/CoursePlaceInfoPanel.tsx @@ -25,23 +25,21 @@ export function CoursePlaceInfoPanel({ const primaryStop = stops[0]; return ( -
-
- +
-

{courseTitle}

+

{courseTitle}

-
+
{stop.walkingTime}
@@ -78,18 +76,18 @@ export function CoursePlaceInfoPanel({
{primaryStop ? ( -
-

{primaryStop.name}

-

{primaryStop.address}

+
+

{primaryStop.name}

+

{primaryStop.address}

-
+
{primaryStop.category} {primaryStop.hours} diff --git a/src/components/course-planner/CoursePlaceTagSelector.tsx b/src/components/course-planner/CoursePlaceTagSelector.tsx new file mode 100644 index 0000000..894bf31 --- /dev/null +++ b/src/components/course-planner/CoursePlaceTagSelector.tsx @@ -0,0 +1,111 @@ +import { CategoryChip } from "@/components/map/CategoryChip"; +import type { MapFilterBarProps } from "@/components/map/filters/map-filter-bar-props"; +import { getMapCategoryChipHighlighted } from "@/components/map/filters/map-filter-selection"; +import { TagChip } from "@/components/map/TagChip"; +import { cn } from "@/lib/utils"; +import { MAP_ALL_CATEGORY_FILTER_CHIP } from "@/shared/types/map-home"; + +const CATEGORY_CHIP_GRID_CLASS = + "grid w-full min-w-0 grid-cols-4 gap-2 overflow-visible pb-0.5 pt-0.5"; +const CATEGORY_CHIP_SKELETON_COUNT = 4; + +function CategoryChipSkeletonList() { + return ( +
    + {Array.from({ length: CATEGORY_CHIP_SKELETON_COUNT }, (_, index) => ( +
  • +
    +
  • + ))} +
+ ); +} + +export function CoursePlaceTagSelector({ + categories, + categoryNameByCode, + filterCategories, + isCategoryLoading, + isCategoryError, + onRetryLoadCategories, + activeCategories, + focusedCategory, + onToggleCategory, + isTagPanelOpen, + selectedTagKeysByCategory, + selectedTagCountByCategory, + onToggleTagInCategory, +}: MapFilterBarProps) { + const highlightCtx = { activeCategories, focusedCategory }; + const focusedSection = + isTagPanelOpen && focusedCategory + ? (filterCategories.find((category) => category.code === focusedCategory) ?? null) + : null; + const selectedKeys = focusedSection ? (selectedTagKeysByCategory[focusedSection.code] ?? []) : []; + const focusedTags = + focusedSection?.tagGroups.flatMap((group) => group.tags).filter((tag) => tag.name.trim()) ?? []; + + return ( +
+ {isCategoryLoading ? ( + + ) : ( +
    + {categories.map((category) => ( +
  • + onToggleCategory(category)} + className="w-full min-w-0 justify-center" + /> +
  • + ))} +
+ )} + + {isCategoryError ? ( +
+ +
+ ) : null} + + {focusedSection && focusedTags.length > 0 ? ( +
+ {focusedTags.map((tag) => ( + onToggleTagInCategory(focusedSection.code, tag.code)} + /> + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/course-planner/CoursePlannerActions.tsx b/src/components/course-planner/CoursePlannerActions.tsx index 0e93e51..228c1b6 100644 --- a/src/components/course-planner/CoursePlannerActions.tsx +++ b/src/components/course-planner/CoursePlannerActions.tsx @@ -14,11 +14,11 @@ export function CoursePlannerActions({ onReset, }: CoursePlannerActionsProps) { return ( -
+
diff --git a/src/components/course-planner/CoursePlannerMapPreview.tsx b/src/components/course-planner/CoursePlannerMapPreview.tsx index 242f538..f2b8396 100644 --- a/src/components/course-planner/CoursePlannerMapPreview.tsx +++ b/src/components/course-planner/CoursePlannerMapPreview.tsx @@ -1,98 +1,17 @@ -import { MapPin, Search } from "lucide-react"; - -const categoryLabels = ["맛집", "카페", "놀거리", "기타"]; - type CoursePlannerMapPreviewProps = { statusMessage?: string; - onMapClick?: () => void; }; -export function CoursePlannerMapPreview({ - statusMessage, - onMapClick, -}: CoursePlannerMapPreviewProps) { +export function CoursePlannerMapPreview({ statusMessage }: CoursePlannerMapPreviewProps) { return ( -
- - +
{statusMessage ? ( -
- {statusMessage} +
+
+ {statusMessage} +
) : null} - -
-
- - 실한 두줄 지도 -
-
- -
-
- 저장해둔 장소를 검색해 보세요 - -
- -
- {categoryLabels.map((label, index) => ( - - {index === 0 ? "🍴 " : index === 1 ? "☕ " : index === 2 ? "🚩 " : "+ "} - {label} - - ))} -
-
-
- ); -} - -type MapMarkerProps = { - className: string; - label: string; -}; - -function MapMarker({ className, label }: MapMarkerProps) { - return ( - - - {label} - - - +
); } diff --git a/src/components/course-planner/CoursePlannerPanel.tsx b/src/components/course-planner/CoursePlannerPanel.tsx index 9a6ebb9..b80bbe0 100644 --- a/src/components/course-planner/CoursePlannerPanel.tsx +++ b/src/components/course-planner/CoursePlannerPanel.tsx @@ -1,44 +1,36 @@ -import { CalendarDays, Coffee, Flag, MoreHorizontal, Utensils } from "lucide-react"; +import { CalendarDays } 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 { PlaceTypeChip } from "@/components/course-planner/PlaceTypeChip"; - -export type PlaceTypeId = "restaurant" | "cafe" | "activity" | "etc"; +import type { MapFilterBarProps } from "@/components/map/filters/map-filter-bar-props"; type CoursePlannerPanelProps = { regionValue: string; dateTimeValue: string; - selectedPlaceTypeIds: PlaceTypeId[]; canGenerate: boolean; + placeFilterBarProps: MapFilterBarProps; onOpenRegionSelect: () => void; onOpenDateTimeSelect: () => void; - onTogglePlaceType: (placeTypeId: PlaceTypeId) => void; onGenerate: () => void; onReset: () => void; }; -const placeTypes: Array<{ id: PlaceTypeId; label: string; icon: React.ReactNode }> = [ - { id: "restaurant", label: "맛집", icon: }, - { id: "cafe", label: "카페", icon: }, - { id: "activity", label: "놀거리", icon: }, - { id: "etc", label: "기타", icon: }, -]; - export function CoursePlannerPanel({ regionValue, dateTimeValue, - selectedPlaceTypeIds, canGenerate, + placeFilterBarProps, onOpenRegionSelect, onOpenDateTimeSelect, - onTogglePlaceType, onGenerate, onReset, }: CoursePlannerPanelProps) { return ( -
-

맞춤 데이트코스 설정하기

+
+

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

- 장소 종류 -
- {placeTypes.map((type) => ( - onTogglePlaceType(type.id)} - /> - ))} -
+ 장소 종류 +
diff --git a/src/components/course-planner/CourseResultPanel.tsx b/src/components/course-planner/CourseResultPanel.tsx index b56ee14..a80041a 100644 --- a/src/components/course-planner/CourseResultPanel.tsx +++ b/src/components/course-planner/CourseResultPanel.tsx @@ -20,11 +20,9 @@ export function CourseResultPanel({ onSelectCourse, }: CourseResultPanelProps) { return ( -
-
- -

맞춤 데이트코스 확인하기

-

+

+

맞춤 데이트코스 확인하기

+

마음에 드는 코스를 선택해서 장소 정보를 확인해보세요.

@@ -39,15 +37,17 @@ export function CourseResultPanel({ className={cn( "focus-visible:ring-ring/50 flex h-12 items-center justify-between rounded-lg border px-4 text-left transition-colors focus-visible:ring-3 focus-visible:outline-none", selected - ? "border-[#f06f6b] bg-[#fff0ee]" - : "border-[#dedede] bg-white hover:bg-[#fafafa]", + ? "border-primary bg-primary/10" + : "border-border bg-background hover:bg-muted/35", )} > - {course.title} - {course.description} + {course.title} + + {course.description} + - + ); })} diff --git a/src/components/course-planner/DateTimeSelectionPanel.tsx b/src/components/course-planner/DateTimeSelectionPanel.tsx index d97827c..b5dd183 100644 --- a/src/components/course-planner/DateTimeSelectionPanel.tsx +++ b/src/components/course-planner/DateTimeSelectionPanel.tsx @@ -1,225 +1,209 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { AmPmTimeWheelGroup } from "@/components/course-planner/AmPmTimeWheelGroup"; +import { isEndAfterStart, isHmString } from "@/components/course-planner/course-date-time"; import { cn } from "@/lib/utils"; -export type DateTimeSelection = { - date: string; - weekday: string; - startTime: string | null; - endTime: string | null; -}; +const weekdayLabels = ["일", "월", "화", "수", "목", "금", "토"]; -type DateTimeSelectionPanelProps = { - selectedDate: string; - selectedStartTime: string | null; - selectedEndTime: string | null; +function parseDateAnchor(value: string | null) { + if (!value) return new Date(); + + const match = /^(\d{4})\.(\d{2})\.(\d{2})$/.exec(value); + if (!match) return new Date(); + + const [, year, month, day] = match; + return new Date(Number(year), Number(month) - 1, Number(day)); +} + +function startOfMonth(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +function formatDateValue(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}.${month}.${day}`; +} + +function getMonthMatrixBase(date: Date) { + const year = date.getFullYear(); + const month = date.getMonth(); + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + + return { + year, + month, + startBlankCount: firstDay.getDay(), + dayCount: lastDay.getDate(), + }; +} + +type DateCalendarPanelProps = { + selectedDate: string | null; onSelectDate: (date: string) => void; - onSelectStartTime: (time: string | null) => void; - onSelectEndTime: (time: string | null) => void; - onClose: () => void; - onConfirm: () => void; }; -const weekdayLabels = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; -const weekdaysKo = ["\uc77c", "\uc6d4", "\ud654", "\uc218", "\ubaa9", "\uae08", "\ud1a0"]; -const monthLabel = "April 2026"; -const monthStartBlankCount = 3; -const dateOptions = Array.from({ length: 30 }, (_, index) => { - const day = index + 1; - const date = `2026.04.${String(day).padStart(2, "0")}`; - const weekday = weekdaysKo[new Date(2026, 3, day).getDay()]; - return { date, day, weekday }; -}); -const startTimeOptions = ["11:00", "12:00", "13:00", "14:00", "15:00"]; -const endTimeOptions = ["18:00", "19:00", "20:00", "21:00"]; - -export function getDateTimeDisplayValue(selection: DateTimeSelection | null) { - if (!selection) return ""; - if (!selection.startTime || !selection.endTime) { - return `${selection.date} ${selection.weekday}\uc694\uc77c`; - } - return `${selection.date} ${selection.weekday}\uc694\uc77c ${selection.startTime} ~ ${selection.endTime}`; -} +/** 날짜만 선택하는 캘린더 카드 */ +export function DateCalendarPanel({ selectedDate, onSelectDate }: DateCalendarPanelProps) { + const parsedAnchorDate = useMemo(() => parseDateAnchor(selectedDate), [selectedDate]); + const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(parsedAnchorDate)); + + useEffect(() => { + if (selectedDate !== null) return; + const id = requestAnimationFrame(() => { + const now = new Date(); + setVisibleMonth(new Date(now.getFullYear(), now.getMonth(), 1)); + }); + return () => cancelAnimationFrame(id); + }, [selectedDate]); + + const { year, month, startBlankCount, dayCount } = useMemo( + () => getMonthMatrixBase(visibleMonth), + [visibleMonth], + ); + const monthLabel = `${visibleMonth.getFullYear()}년 ${visibleMonth.getMonth() + 1}월`; -export function DateTimeSelectionPanel({ - selectedDate, - selectedStartTime, - selectedEndTime, - onSelectDate, - onSelectStartTime, - onSelectEndTime, - onClose, - onConfirm, -}: DateTimeSelectionPanelProps) { - const selectedDateOption = - dateOptions.find((option) => option.date === selectedDate) ?? dateOptions[19]; - const hasTimeRange = selectedStartTime != null && selectedEndTime != null; - const confirmLabel = hasTimeRange - ? `${selectedDateOption.date} ${selectedStartTime} ~ ${selectedEndTime} \uc124\uc815\ud558\uae30` - : `${selectedDateOption.date} \uc124\uc815\ud558\uae30`; + const moveMonth = (offset: number) => { + setVisibleMonth((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1)); + }; return ( -
-
-
- - -
- {monthLabel} - -
- -
- - -
-
- -
-
- {weekdayLabels.map((weekday) => ( - - {weekday} - - ))} -
- -
- {Array.from({ length: monthStartBlankCount }).map((_, index) => ( - - ))} - {dateOptions.map((option) => { - const selected = option.date === selectedDateOption.date; - return ( - - ); - })} -
+
+
+ + +
+ {monthLabel}
-
-
- Time - -
- -
- { - onSelectStartTime(time); - if (!selectedEndTime || time >= selectedEndTime) { - onSelectEndTime("21:00"); - } - }} - /> - { - if (selectedStartTime && time <= selectedStartTime) return; - onSelectEndTime(time); - }} - /> -
+ +
+ +
+
+ {weekdayLabels.map((weekday) => ( + + {weekday} + + ))}
-
- +
+ {Array.from({ length: startBlankCount }).map((_, index) => ( + + ))} + {Array.from({ length: dayCount }).map((_, index) => { + const day = index + 1; + const date = new Date(year, month, day); + const dateValue = formatDateValue(date); + const selected = selectedDate !== null && dateValue === selectedDate; + + return ( + + ); + })}
-
+
); } -type TimeChipRowProps = { - label: string; - selectedTime: string | null; - options: string[]; - onSelect: (time: string) => void; +type DateTimeWheelsPanelProps = { + selectedDate: string | null; + selectedStartTime: string | null; + selectedEndTime: string | null; + onSelectStartTime: (time: string | null) => void; + onSelectEndTime: (time: string | null) => void; }; -function TimeChipRow({ label, selectedTime, options, onSelect }: TimeChipRowProps) { +/** 시작·종료 시간 휠 (날짜가 정해진 뒤 단계 화면에서만 사용) */ +export function DateTimeWheelsPanel({ + selectedDate, + selectedStartTime, + selectedEndTime, + onSelectStartTime, + onSelectEndTime, +}: DateTimeWheelsPanelProps) { + const showStartWheels = selectedDate !== null; + const showEndWheels = showStartWheels && isHmString(selectedStartTime); + + if (!showStartWheels) return null; + return ( -
- {label} -
- {options.map((time) => { - const selected = time === selectedTime; - return ( - - ); - })} -
+
+ { + onSelectStartTime(time); + if (time === null) { + onSelectEndTime(null); + return; + } + if ( + isHmString(selectedEndTime) && + isHmString(time) && + !isEndAfterStart(time, selectedEndTime) + ) { + onSelectEndTime(null); + } + }} + /> + {showEndWheels ? ( + { + if ( + time !== null && + isHmString(selectedStartTime) && + !isEndAfterStart(selectedStartTime, time) + ) { + onSelectEndTime(null); + return; + } + onSelectEndTime(time); + }} + /> + ) : ( +
+ )}
); } diff --git a/src/components/course-planner/DateTimeSelectionScreen.tsx b/src/components/course-planner/DateTimeSelectionScreen.tsx new file mode 100644 index 0000000..ca00a3d --- /dev/null +++ b/src/components/course-planner/DateTimeSelectionScreen.tsx @@ -0,0 +1,130 @@ +import { ChevronLeft, X } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { isEndAfterStart, isHmString } from "@/components/course-planner/course-date-time"; +import { + DateCalendarPanel, + DateTimeWheelsPanel, +} from "@/components/course-planner/DateTimeSelectionPanel"; +import { cn } from "@/lib/utils"; + +type DateTimeStep = "date" | "time"; + +type DateTimeSelectionScreenProps = { + selectedDate: string | null; + selectedStartTime: string | null; + selectedEndTime: string | null; + onSelectDate: (date: string) => void; + onSelectStartTime: (time: string | null) => void; + onSelectEndTime: (time: string | null) => void; + onClose: () => void; + onConfirm: () => void; +}; + +function formatSelectedDateLine(dateStr: string) { + const d = new Date(dateStr.replaceAll(".", "/")); + const weekday = d.toLocaleDateString("ko-KR", { weekday: "long" }); + return `${dateStr} ${weekday}`; +} + +export function DateTimeSelectionScreen({ + selectedDate, + selectedStartTime, + selectedEndTime, + onSelectDate, + onSelectStartTime, + onSelectEndTime, + onClose, + onConfirm, +}: DateTimeSelectionScreenProps) { + const [step, setStep] = useState(() => + selectedDate !== null ? "time" : "date", + ); + + useEffect(() => { + if (selectedDate !== null) return; + const id = requestAnimationFrame(() => { + setStep("date"); + }); + return () => cancelAnimationFrame(id); + }, [selectedDate]); + + const handlePickDate = (date: string) => { + onSelectDate(date); + setStep("time"); + }; + + const canConfirm = + selectedDate !== null && + isHmString(selectedStartTime) && + isHmString(selectedEndTime) && + isEndAfterStart(selectedStartTime, selectedEndTime); + + const headerTitle = step === "date" ? "날짜 선택" : "시간 설정"; + + return ( +
+
+ {step === "time" ? ( + + ) : ( + + )} + +

{headerTitle}

+ + +
+ + {step === "time" && selectedDate !== null ? ( +

+ {formatSelectedDateLine(selectedDate)} +

+ ) : null} + +
+ {step === "date" ? ( + + ) : ( + + )} +
+ + {step === "time" ? ( + + ) : null} +
+ ); +} diff --git a/src/components/course-planner/PlaceTypeChip.tsx b/src/components/course-planner/PlaceTypeChip.tsx deleted file mode 100644 index d350acb..0000000 --- a/src/components/course-planner/PlaceTypeChip.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { ReactNode } from "react"; - -import { cn } from "@/lib/utils"; - -type PlaceTypeChipProps = { - label: string; - icon: ReactNode; - selected?: boolean; - onClick?: () => void; -}; - -export function PlaceTypeChip({ label, icon, selected = false, onClick }: PlaceTypeChipProps) { - return ( - - ); -} diff --git a/src/components/course-planner/RegionSelectionPanel.tsx b/src/components/course-planner/RegionSelectionPanel.tsx index c5d1194..c96df54 100644 --- a/src/components/course-planner/RegionSelectionPanel.tsx +++ b/src/components/course-planner/RegionSelectionPanel.tsx @@ -1,5 +1,6 @@ -import { Search, X } from "lucide-react"; +import { X } from "lucide-react"; +import { SearchField } from "@/components/common/SearchField"; import { cn } from "@/lib/utils"; type RegionSelectionPanelProps = { @@ -30,41 +31,33 @@ export function RegionSelectionPanel({ onConfirm, }: RegionSelectionPanelProps) { const districts = districtsByCity[selectedCity] ?? districtsByCity["서울"]; - const confirmLabel = - selectedDistrict === "전체" - ? `${selectedCity} 전체 설정하기` - : `${selectedCity} ${selectedDistrict} 설정하기`; return ( -
-
- +
-

지역설정

+

지역설정

- + -
-
-
+
+
+
시/도
@@ -78,8 +71,8 @@ export function RegionSelectionPanel({ className={cn( "h-11 px-4 text-left text-sm transition-colors", selected - ? "bg-[#fff0ee] font-semibold text-[#f06f6b]" - : "text-[#8a8f98] hover:bg-[#fafafa]", + ? "bg-primary/10 text-primary font-semibold" + : "text-muted-foreground hover:bg-muted/35", )} > {city} @@ -90,7 +83,7 @@ export function RegionSelectionPanel({
-
+
시/구/군
@@ -104,8 +97,8 @@ export function RegionSelectionPanel({ className={cn( "h-11 px-4 text-left text-sm transition-colors", selected - ? "bg-[#ffe6e4] font-semibold text-[#f06f6b]" - : "text-[#8a8f98] hover:bg-[#fafafa]", + ? "bg-primary/15 text-primary font-semibold" + : "text-muted-foreground hover:bg-muted/35", )} > {district} @@ -119,9 +112,9 @@ export function RegionSelectionPanel({
); diff --git a/src/components/course-planner/course-date-time.ts b/src/components/course-planner/course-date-time.ts new file mode 100644 index 0000000..d3b6806 --- /dev/null +++ b/src/components/course-planner/course-date-time.ts @@ -0,0 +1,34 @@ +export type DateTimeSelection = { + date: string; + weekday: string; + startTime: string | null; + endTime: string | null; +}; + +/** 30분 단위 HH:mm (00:00 ~ 23:30). 숫자 `0`(자정)은 falsy로 오인되므로 여기서만 인정합니다. */ +const HM_REGEX = /^([01]\d|2[0-3]):[03]0$/; + +export function isHmString(value: unknown): value is string { + return typeof value === "string" && HM_REGEX.test(value.trim()); +} + +export function hmToMinutes(value: string): number { + const [h, m] = value.trim().split(":").map(Number); + return h * 60 + m; +} + +/** 같은 날 기준으로 종료 시각이 시작보다 뒤인지 (동일 시각은 불가). */ +export function isEndAfterStart(start: string, end: string): boolean { + if (!isHmString(start) || !isHmString(end)) return false; + return hmToMinutes(end) > hmToMinutes(start); +} + +export function getDateTimeDisplayValue(selection: DateTimeSelection | null) { + if (!selection) return ""; + const startOk = isHmString(selection.startTime); + const endOk = isHmString(selection.endTime); + if (!startOk || !endOk) { + return `${selection.date} ${selection.weekday}요일`; + } + return `${selection.date} ${selection.weekday}요일 ${selection.startTime} ~ ${selection.endTime}`; +} diff --git a/src/components/ui/BottomSheet.tsx b/src/components/ui/BottomSheet.tsx index 0af9b89..15a7613 100644 --- a/src/components/ui/BottomSheet.tsx +++ b/src/components/ui/BottomSheet.tsx @@ -13,7 +13,9 @@ export type BottomSheetProps = { className?: string; overlayClassName?: string; panelClassName?: string; + contentClassName?: string; hideHandle?: boolean; + enableHistory?: boolean; }; export function BottomSheet({ @@ -23,13 +25,16 @@ export function BottomSheet({ className, overlayClassName, panelClassName, + contentClassName, hideHandle = false, + enableHistory = true, }: BottomSheetProps) { const { isRendered, isVisible, requestClose } = useOverlayFlowController({ open, onClose, historyStateKey: "bottomSheet", transitionMs: BOTTOM_SHEET_TRANSITION_MS, + enableHistory, }); const [dragOffsetY, setDragOffsetY] = useState(0); @@ -138,7 +143,10 @@ export function BottomSheet({
{children}
diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx new file mode 100644 index 0000000..be2751b --- /dev/null +++ b/src/components/ui/Select.tsx @@ -0,0 +1,83 @@ +import { Check, ChevronDown } from "lucide-react"; +import { Select as SelectPrimitive } from "radix-ui"; +import type { ComponentPropsWithoutRef } from "react"; + +import { cn } from "@/lib/utils"; + +export const Select = SelectPrimitive.Root; +export const SelectGroup = SelectPrimitive.Group; +export const SelectValue = SelectPrimitive.Value; + +export function SelectTrigger({ + className, + children, + ...props +}: ComponentPropsWithoutRef) { + return ( + + {children} + + + + + ); +} + +export function SelectContent({ + className, + children, + position = "popper", + ...props +}: ComponentPropsWithoutRef) { + return ( + + + + {children} + + + + ); +} + +export function SelectItem({ + className, + children, + ...props +}: ComponentPropsWithoutRef) { + return ( + + + + + + + {children} + + ); +} diff --git a/src/features/room/hooks/useOverlayFlowController.ts b/src/features/room/hooks/useOverlayFlowController.ts index d8f5090..a2e7bd8 100644 --- a/src/features/room/hooks/useOverlayFlowController.ts +++ b/src/features/room/hooks/useOverlayFlowController.ts @@ -6,6 +6,7 @@ type UseOverlayFlowControllerOptions = { historyStateKey: string; transitionMs?: number; enableEscape?: boolean; + enableHistory?: boolean; }; type UseOverlayFlowControllerResult = { @@ -28,6 +29,7 @@ export function useOverlayFlowController({ historyStateKey, transitionMs = DEFAULT_TRANSITION_MS, enableEscape = true, + enableHistory = true, }: UseOverlayFlowControllerOptions): UseOverlayFlowControllerResult { const [isRendered, setIsRendered] = useState(open); const [isVisible, setIsVisible] = useState(false); @@ -37,7 +39,7 @@ export function useOverlayFlowController({ const closedByPopStateRef = useRef(false); const requestClose = useCallback(() => { - if (historyPushedRef.current) { + if (enableHistory && historyPushedRef.current) { historyPushedRef.current = false; onClose(); window.history.back(); @@ -45,7 +47,7 @@ export function useOverlayFlowController({ } onClose(); - }, [onClose]); + }, [enableHistory, onClose]); useEffect(() => { if (open) { @@ -70,7 +72,7 @@ export function useOverlayFlowController({ setIsVisible(false); }); - if (historyPushedRef.current && !closedByPopStateRef.current) { + if (enableHistory && historyPushedRef.current && !closedByPopStateRef.current) { historyPushedRef.current = false; window.history.back(); } @@ -87,10 +89,10 @@ export function useOverlayFlowController({ closeTimerRef.current = null; } }; - }, [open, transitionMs]); + }, [enableHistory, open, transitionMs]); useEffect(() => { - if (!open) { + if (!open || !enableHistory) { return; } @@ -111,7 +113,7 @@ export function useOverlayFlowController({ return () => { window.removeEventListener("popstate", handlePopState); }; - }, [historyStateKey, onClose, open]); + }, [enableHistory, historyStateKey, onClose, open]); useEffect(() => { if (!open || !enableEscape) { diff --git a/src/index.css b/src/index.css index 30b789f..b6aaab6 100644 --- a/src/index.css +++ b/src/index.css @@ -449,11 +449,23 @@ body { * { @apply border-border outline-ring/50; } + /** + * 비입력 영역에는 텍스트 삽입 캐럿이 보이지 않도록 한다. + * 실제 입력·편집 가능 요소만 예외 + */ + html { + @apply font-sans; + caret-color: transparent; + } body { @apply bg-background text-foreground; + caret-color: transparent; } - html { - @apply font-sans; + input, + textarea, + select, + [contenteditable]:not([contenteditable="false"]) { + caret-color: auto; } /** 클릭 가능한 버튼·역할이 버튼인 요소 — 포인터 커서 통일 */ diff --git a/src/pages/tabs/CoursePlannerPage.tsx b/src/pages/tabs/CoursePlannerPage.tsx index de69182..12f01c2 100644 --- a/src/pages/tabs/CoursePlannerPage.tsx +++ b/src/pages/tabs/CoursePlannerPage.tsx @@ -1,34 +1,45 @@ -import { useEffect, useState } from "react"; -import { Navigate } from "react-router-dom"; +import { lazy, Suspense, useCallback, useEffect, useState } from "react"; +import { Navigate, useNavigate } from "react-router-dom"; import { BottomNavigationBar } from "@/components/common/BottomNavigationBar"; import { BottomNavToast } from "@/components/common/BottomNavToast"; +import { + type DateTimeSelection, + getDateTimeDisplayValue, + isEndAfterStart, + isHmString, +} from "@/components/course-planner/course-date-time"; import { CourseEditPanel } from "@/components/course-planner/CourseEditPanel"; import { CourseGenerationLoadingPanel } from "@/components/course-planner/CourseGenerationLoadingPanel"; import { CoursePlaceInfoPanel, type CourseStop, } from "@/components/course-planner/CoursePlaceInfoPanel"; +import { CoursePlannerBottomSheet } from "@/components/course-planner/CoursePlannerBottomSheet"; import { CoursePlannerMapPreview } from "@/components/course-planner/CoursePlannerMapPreview"; -import { - CoursePlannerPanel, - type PlaceTypeId, -} from "@/components/course-planner/CoursePlannerPanel"; +import { CoursePlannerPanel } from "@/components/course-planner/CoursePlannerPanel"; import { type CourseOption, CourseResultPanel, } from "@/components/course-planner/CourseResultPanel"; -import { - type DateTimeSelection, - DateTimeSelectionPanel, - getDateTimeDisplayValue, -} from "@/components/course-planner/DateTimeSelectionPanel"; +import { DateTimeSelectionScreen } from "@/components/course-planner/DateTimeSelectionScreen"; import { RegionSelectionPanel } from "@/components/course-planner/RegionSelectionPanel"; +import { MapHeader } from "@/components/map/MapHeader"; +import { useMapSearchFilters } from "@/features/map/hooks/use-map-search-filters"; +import { usePlaceFilterData } from "@/features/map/hooks/use-place-filter-data"; import { useBottomNavController } from "@/hooks/use-bottom-nav-controller"; +import { MAP_INITIAL_CENTER, SAVED_PLACE_MOCKS } from "@/pages/map/map-home-mock"; +import MyHomePage_WithDetail from "@/pages/MyHomePage_WithDetail"; +import { MAP_ALL_CATEGORY_FILTER_CHIP } from "@/shared/types/map-home"; import { useRoomSelectionStore } from "@/store/room-selection-store"; type CoursePlannerMode = "form" | "region" | "datetime" | "loading" | "result" | "detail" | "edit"; +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 mockCourses: CourseOption[] = [ { id: "course-1", title: "코스 1", description: "캠퍼스 산책부터 감동까지" }, { id: "course-2", title: "코스 2", description: "카페와 전시를 가볍게" }, @@ -66,22 +77,69 @@ type CoursePlannerPageProps = { skipRoomGuard?: boolean; }; +function CourseDevMapBackground() { + const { toastMessage, handleSelectBottomNav } = useBottomNavController(); + + return ( +
+ + +
+ }> + + +
+ +
+ + +
+
+ ); +} + export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlannerPageProps) { + const navigate = useNavigate(); const selectedRoom = useRoomSelectionStore((s) => s.selectedRoom); - const { toastMessage, handleSelectBottomNav } = useBottomNavController(); const [mode, setMode] = useState("form"); const [regionValue, setRegionValue] = useState(""); const [draftCity, setDraftCity] = useState("서울"); const [draftDistrict, setDraftDistrict] = useState("강남구"); const [dateTimeValue, setDateTimeValue] = useState(null); - const [draftDate, setDraftDate] = useState("2026.04.20"); - const [draftStartTime, setDraftStartTime] = useState("13:00"); - const [draftEndTime, setDraftEndTime] = useState("21:00"); - const [selectedPlaceTypeIds, setSelectedPlaceTypeIds] = useState(["restaurant"]); + const [draftDate, setDraftDate] = useState(null); + const [draftStartTime, setDraftStartTime] = useState(null); + const [draftEndTime, setDraftEndTime] = useState(null); const [selectedCourseId, setSelectedCourseId] = useState(mockCourses[0]?.id ?? ""); const [courseTitle, setCourseTitle] = useState(mockCourses[0]?.title ?? "코스 1"); const [courseStops, setCourseStops] = useState(mockStops); const [completionNoticeVisible, setCompletionNoticeVisible] = useState(false); + const { + categories, + categoryNameByCode, + filterCategories, + isInitialLoading, + isInitialError, + retryLoad, + } = usePlaceFilterData(); + const { + activeCategories, + focusedCategory, + toggleCategory, + closeTagPanel, + isTagPanelOpen, + selectedTagKeysByCategory, + selectedTagCountByCategory, + toggleTagInCategory, + resetFocusedCategoryTags, + } = useMapSearchFilters({ + places: SAVED_PLACE_MOCKS, + filterCategories, + }); useEffect(() => { if (mode !== "loading") return; @@ -104,23 +162,12 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann return () => window.clearTimeout(timerId); }, [completionNoticeVisible]); - if (!skipRoomGuard && !selectedRoom) { - return ; - } - - const canGenerate = regionValue.trim().length > 0 && selectedPlaceTypeIds.length > 0; - - const handleSelectCity = (city: string) => { - setDraftCity(city); - setDraftDistrict("전체"); - }; - - const handleConfirmRegion = () => { - setRegionValue(draftDistrict === "전체" ? draftCity : `${draftCity} ${draftDistrict}`); - setMode("form"); - }; + const applyDateTimeFromDrafts = useCallback(() => { + if (!draftDate) { + setDateTimeValue(null); + return; + } - const handleConfirmDateTime = () => { const weekday = new Date(draftDate.replaceAll(".", "/")) .toLocaleDateString("ko-KR", { weekday: "short" }) .replace("요일", ""); @@ -131,16 +178,48 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann startTime: draftStartTime, endTime: draftEndTime, }); + }, [draftDate, draftStartTime, draftEndTime]); + + const handleOpenDateTimeSelect = useCallback(() => { + closeTagPanel(); + setMode("datetime"); + }, [closeTagPanel]); + + const handleCloseDateTimeScreen = useCallback(() => { + setMode("form"); + }, []); + + const handleConfirmDateTime = useCallback(() => { + if ( + !draftDate || + !isHmString(draftStartTime) || + !isHmString(draftEndTime) || + !isEndAfterStart(draftStartTime, draftEndTime) + ) { + return; + } + applyDateTimeFromDrafts(); setMode("form"); + }, [applyDateTimeFromDrafts, draftDate, draftEndTime, draftStartTime]); + + if (!skipRoomGuard && !selectedRoom) { + return ; + } + + const canGenerate = regionValue.trim().length > 0; + + const handleDismissCourse = () => { + navigate("/map"); }; - const handleTogglePlaceType = (placeTypeId: PlaceTypeId) => { - setSelectedPlaceTypeIds((current) => { - if (current.includes(placeTypeId)) { - return current.filter((id) => id !== placeTypeId); - } - return [...current, placeTypeId]; - }); + const handleSelectCity = (city: string) => { + setDraftCity(city); + setDraftDistrict("전체"); + }; + + const handleConfirmRegion = () => { + setRegionValue(draftDistrict === "전체" ? draftCity : `${draftCity} ${draftDistrict}`); + setMode("form"); }; const handleResetPlanner = () => { @@ -148,10 +227,10 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann setDraftCity("서울"); setDraftDistrict("강남구"); setDateTimeValue(null); - setDraftDate("2026.04.20"); - setDraftStartTime("13:00"); - setDraftEndTime("21:00"); - setSelectedPlaceTypeIds(["restaurant"]); + setDraftDate(null); + setDraftStartTime(null); + setDraftEndTime(null); + toggleCategory(MAP_ALL_CATEGORY_FILTER_CHIP); setCompletionNoticeVisible(false); setMode("form"); }; @@ -176,19 +255,40 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann setMode("detail"); }; - const handleMapClick = () => { - if (mode === "detail" || mode === "edit") { - setMode("detail"); - } - }; - const statusMessage = completionNoticeVisible ? "데이트코스가 완성되었습니다" : undefined; + const placeFilterBarProps = { + categories, + categoryNameByCode, + filterCategories, + isCategoryLoading: isInitialLoading, + isCategoryError: isInitialError, + onRetryLoadCategories: () => { + void retryLoad(); + }, + activeCategories, + focusedCategory, + onToggleCategory: toggleCategory, + isTagPanelOpen, + selectedTagKeysByCategory, + selectedTagCountByCategory, + onToggleTagInCategory: toggleTagInCategory, + onResetFocusedCategoryTags: resetFocusedCategoryTags, + onCloseTagPanel: closeTagPanel, + }; return ( -
-
- - + <> + {selectedRoom ? : } + + {statusMessage ? ( +
+
+ {statusMessage} +
+
+ ) : null} + + {mode === "region" ? ( setMode("form")} + onClose={handleCloseDateTimeScreen} onConfirm={handleConfirmDateTime} /> ) : null} @@ -217,11 +317,10 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann setMode("region")} - onOpenDateTimeSelect={() => setMode("datetime")} - onTogglePlaceType={handleTogglePlaceType} + onOpenDateTimeSelect={handleOpenDateTimeSelect} onGenerate={handleGenerateCourse} onReset={handleResetPlanner} /> @@ -254,10 +353,7 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann onSave={handleSaveCourseEdit} /> ) : null} -
- - - -
+ + ); } From aeff099ae4ece3e7a5c54c3b26977908c89f5092 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Thu, 30 Apr 2026 23:47:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EA=B2=BD=EB=A1=9C=20UI=20UX=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 56 ++++ package.json | 3 + src/components/common/BottomNavToast.tsx | 17 +- .../course-planner/CourseConfirmModal.tsx | 24 +- .../course-planner/CourseEditPanel.tsx | 155 ---------- .../CourseGenerationLoadingPanel.tsx | 16 +- .../course-planner/CoursePlaceInfoPanel.tsx | 273 ++++++++++++++---- .../CoursePlannerBottomSheet.tsx | 1 - .../course-planner/CourseResultPanel.tsx | 34 ++- .../course-planner/CourseStopEditRow.tsx | 104 +++++++ .../DateTimeSelectionScreen.tsx | 4 +- .../place/BusinessHoursAccordion.tsx | 22 +- src/components/place/PlaceDetailSheet.tsx | 23 +- src/components/room/RoomAddDrawer.tsx | 2 +- src/components/ui/BottomSheet.tsx | 4 +- src/hooks/use-bottom-nav-controller.ts | 30 +- src/pages/map/MapHomePage.tsx | 4 +- src/pages/room/RoomMainPage.tsx | 5 +- src/pages/tabs/CoursePlannerPage.tsx | 99 +++---- src/pages/tabs/MyPage.tsx | 4 +- src/pages/tabs/PlaceListPage.tsx | 4 +- 21 files changed, 549 insertions(+), 335 deletions(-) delete mode 100644 src/components/course-planner/CourseEditPanel.tsx create mode 100644 src/components/course-planner/CourseStopEditRow.tsx diff --git a/package-lock.json b/package-lock.json index 2aab2ac..73b9a0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "udidura", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/noto-sans": "^5.2.10", "@fontsource-variable/noto-sans-kr": "^5.2.10", "@tanstack/react-query": "^5.96.2", @@ -451,6 +454,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.59.1.tgz", diff --git a/package.json b/package.json index 76a193b..503edf4 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "typecheck": "tsc -b" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/noto-sans": "^5.2.10", "@fontsource-variable/noto-sans-kr": "^5.2.10", "@tanstack/react-query": "^5.96.2", diff --git a/src/components/common/BottomNavToast.tsx b/src/components/common/BottomNavToast.tsx index 08762c8..599e20c 100644 --- a/src/components/common/BottomNavToast.tsx +++ b/src/components/common/BottomNavToast.tsx @@ -1,19 +1,28 @@ import { AnimatePresence, motion } from "framer-motion"; +import type { BottomNavToastPlacement } from "@/hooks/use-bottom-nav-controller"; + export type BottomNavToastProps = { message: string; + placement?: BottomNavToastPlacement; }; -export function BottomNavToast({ message }: BottomNavToastProps) { +export function BottomNavToast({ message, placement = "bottom" }: BottomNavToastProps) { + const isTop = placement === "top"; + return ( {message ? (

{message} diff --git a/src/components/course-planner/CourseConfirmModal.tsx b/src/components/course-planner/CourseConfirmModal.tsx index d9a679b..11264e3 100644 --- a/src/components/course-planner/CourseConfirmModal.tsx +++ b/src/components/course-planner/CourseConfirmModal.tsx @@ -1,3 +1,5 @@ +import { createPortal } from "react-dom"; + import { RoomModalShell } from "@/components/room/RoomModalShell"; import { useOverlayFlowController } from "@/features/room/hooks"; import { cn } from "@/lib/utils"; @@ -35,17 +37,20 @@ export function CourseConfirmModal({ return null; } - return ( + return createPortal( -

-

{title}

-

{description}

+
+

{title}

+

{description}

-
+
@@ -53,13 +58,14 @@ export function CourseConfirmModal({ type="button" onClick={onConfirm} className={cn( - "hover:bg-muted/30 h-11 text-sm font-semibold transition-colors", - variant === "danger" ? "text-destructive" : "text-primary", + "hover:bg-muted/25 active:bg-muted/35 flex-1 py-4 text-xs font-medium transition-colors", + variant === "danger" ? "text-destructive" : "text-foreground", )} > {confirmLabel}
- + , + document.body, ); } diff --git a/src/components/course-planner/CourseEditPanel.tsx b/src/components/course-planner/CourseEditPanel.tsx deleted file mode 100644 index 01aa036..0000000 --- a/src/components/course-planner/CourseEditPanel.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { ArrowDown, ArrowUp, ChevronLeft, Trash2 } from "lucide-react"; -import { useState } from "react"; - -import { CourseConfirmModal } from "@/components/course-planner/CourseConfirmModal"; -import type { CourseStop } from "@/components/course-planner/CoursePlaceInfoPanel"; -import { cn } from "@/lib/utils"; - -type CourseEditPanelProps = { - title: string; - stops: CourseStop[]; - onBack: () => void; - onSave: (nextTitle: string, nextStops: CourseStop[]) => void; -}; - -export function CourseEditPanel({ title, stops, onBack, onSave }: CourseEditPanelProps) { - const [draftTitle, setDraftTitle] = useState(title); - const [draftStops, setDraftStops] = useState(stops); - const [deleteTargetId, setDeleteTargetId] = useState(null); - const [isSaveConfirmOpen, setIsSaveConfirmOpen] = useState(false); - - const deleteTarget = draftStops.find((stop) => stop.id === deleteTargetId); - - const moveStop = (fromIndex: number, direction: "up" | "down") => { - const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1; - if (toIndex < 0 || toIndex >= draftStops.length) return; - - const nextStops = [...draftStops]; - const [movedStop] = nextStops.splice(fromIndex, 1); - nextStops.splice(toIndex, 0, movedStop); - setDraftStops(nextStops); - }; - - const handleDeleteStop = () => { - if (!deleteTargetId) return; - setDraftStops((current) => current.filter((stop) => stop.id !== deleteTargetId)); - setDeleteTargetId(null); - }; - - const handleConfirmSave = () => { - onSave(draftTitle.trim() || title, draftStops); - }; - - return ( -
-
- -

코스 편집

-
- - - -
-
-

장소 순서

- - 위/아래로 순서를 바꿀 수 있어요 - -
- - {draftStops.map((stop, index) => ( -
- - {index + 1} - - -
-

{stop.name}

-

{stop.address}

-
- -
- - - -
-
- ))} -
- - - - setDeleteTargetId(null)} - onConfirm={handleDeleteStop} - /> - - setIsSaveConfirmOpen(false)} - onConfirm={handleConfirmSave} - /> -
- ); -} diff --git a/src/components/course-planner/CourseGenerationLoadingPanel.tsx b/src/components/course-planner/CourseGenerationLoadingPanel.tsx index 5a0d19a..cb02f77 100644 --- a/src/components/course-planner/CourseGenerationLoadingPanel.tsx +++ b/src/components/course-planner/CourseGenerationLoadingPanel.tsx @@ -1,13 +1,19 @@ import { Loader2 } from "lucide-react"; -export function CourseGenerationLoadingPanel() { +type CourseGenerationLoadingPanelProps = { + /** 현재 컨텍스트 방 이름 (`useRoomSelectionStore`) */ + roomName: string; +}; + +export function CourseGenerationLoadingPanel({ roomName }: CourseGenerationLoadingPanelProps) { + const label = roomName.trim() || "방"; + return ( -
+

- 실심 한 두줄 방을 위한 -
- 맞춤 데이트코스를 생성하고 있어요 + {label} + 맞춤 데이트코스를 생성하고 있어요

void; - onEdit: () => void; + /** `fromEditMode`: 편집 후 저장이면 상위에서 상세 화면만 갱신, 아니면 신규 저장 플로우(예: 플래너 초기화) */ + onSave: (nextTitle: string, nextStops: CourseStop[], fromEditMode: boolean) => void; }; export function CoursePlaceInfoPanel({ courseTitle, stops, onBack, - onEdit, + onSave, }: CoursePlaceInfoPanelProps) { - const primaryStop = stops[0]; + const openDetail = usePlaceDetailStore((s) => s.openDetail); + + const [isEditing, setIsEditing] = useState(false); + const [draftTitle, setDraftTitle] = useState(courseTitle); + const [draftStops, setDraftStops] = useState(stops); + const [selectedStopId, setSelectedStopId] = useState(null); + const [isSaveConfirmOpen, setIsSaveConfirmOpen] = useState(false); + + const removeDraftStop = useCallback((stopId: string) => { + setDraftStops((current) => current.filter((stop) => stop.id !== stopId)); + setSelectedStopId((id) => (id === stopId ? null : id)); + }, []); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 10 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + setDraftStops((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over.id); + if (oldIndex < 0 || newIndex < 0) return items; + return arrayMove(items, oldIndex, newIndex); + }); + }, []); + + const handleStartEdit = () => { + setDraftTitle(courseTitle); + setDraftStops([...stops]); + setSelectedStopId(null); + setIsEditing(true); + }; + + const handleCancelEdit = () => { + setDraftTitle(courseTitle); + setDraftStops(stops); + setSelectedStopId(null); + setIsEditing(false); + }; + + const handleConfirmSave = () => { + const nextTitle = (isEditing ? draftTitle.trim() : courseTitle.trim()) || courseTitle; + const nextStops = isEditing ? draftStops : stops; + onSave(nextTitle, nextStops, isEditing); + setIsEditing(false); + setSelectedStopId(null); + setIsSaveConfirmOpen(false); + }; + + const displayStops = isEditing ? draftStops : stops; return ( -
+
-

{courseTitle}

- -
- -
-
- - - -
-
- {stops.map((stop) => ( -
-

{stop.name}

-

{stop.address}

+
+ {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} +

+ + )} +
+
+ +
+ {isEditing ? ( + + s.id)} + strategy={verticalListSortingStrategy} + > + {draftStops.map((stop, index) => ( + + setSelectedStopId((current) => (current === stop.id ? null : stop.id)) + } + onRequestDelete={() => removeDraftStop(stop.id)} + /> + ))} + + + ) : ( + displayStops.map((stop, index) => { + const isLast = index === displayStops.length - 1; + + return ( +
+
+ + {!isLast ? ( +
+ +
+ ) : null} +
-
- - {stop.walkingTime} +
+

{stop.name}

+

{stop.address}

+ + + {!isLast ? ( +
+ + {stop.walkingTime} +
+ ) : null} +
- - ))} -
+ ); + }) + )}
- {primaryStop ? ( -
-

{primaryStop.name}

-

{primaryStop.address}

- + {isEditing ? ( +
+ - -
- {primaryStop.category} - - {primaryStop.hours} - - -
- ) : null} + ) : ( + + )} + + setIsSaveConfirmOpen(false)} + onConfirm={handleConfirmSave} + />
); } diff --git a/src/components/course-planner/CoursePlannerBottomSheet.tsx b/src/components/course-planner/CoursePlannerBottomSheet.tsx index 462778a..498a613 100644 --- a/src/components/course-planner/CoursePlannerBottomSheet.tsx +++ b/src/components/course-planner/CoursePlannerBottomSheet.tsx @@ -18,7 +18,6 @@ export function CoursePlannerBottomSheet({ open={open} onClose={onClose} panelClassName="max-h-[calc(100dvh-2rem)]" - contentClassName="pb-[max(2rem,env(safe-area-inset-bottom))]" enableHistory={false} > {children} diff --git a/src/components/course-planner/CourseResultPanel.tsx b/src/components/course-planner/CourseResultPanel.tsx index a80041a..b7b46b7 100644 --- a/src/components/course-planner/CourseResultPanel.tsx +++ b/src/components/course-planner/CourseResultPanel.tsx @@ -20,13 +20,15 @@ export function CourseResultPanel({ onSelectCourse, }: CourseResultPanelProps) { return ( -
-

맞춤 데이트코스 확인하기

-

+

+

+ 맞춤 데이트코스 확인하기 +

+

마음에 드는 코스를 선택해서 장소 정보를 확인해보세요.

-
+
{courses.map((course) => { const selected = course.id === selectedCourseId; return ( @@ -35,19 +37,27 @@ export function CourseResultPanel({ type="button" onClick={() => onSelectCourse(course.id)} className={cn( - "focus-visible:ring-ring/50 flex h-12 items-center justify-between rounded-lg border px-4 text-left transition-colors focus-visible:ring-3 focus-visible:outline-none", + "focus-visible:ring-ring/50 flex min-h-20 w-full gap-3 rounded-xl border px-4 py-4 text-left transition-colors focus-visible:ring-3 focus-visible:outline-none", selected - ? "border-primary bg-primary/10" - : "border-border bg-background hover:bg-muted/35", + ? "border-primary bg-primary/10 shadow-sm" + : "border-border bg-background hover:bg-muted/40", )} > - - {course.title} - +
+ + {course.title} + + {course.description} - - +
+ ); })} diff --git a/src/components/course-planner/CourseStopEditRow.tsx b/src/components/course-planner/CourseStopEditRow.tsx new file mode 100644 index 0000000..3dafb7a --- /dev/null +++ b/src/components/course-planner/CourseStopEditRow.tsx @@ -0,0 +1,104 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical } from "lucide-react"; + +import type { CourseStop } from "@/components/course-planner/CoursePlaceInfoPanel"; +import { cn } from "@/lib/utils"; + +const DELETE_STRIP_W_CLASS = "w-[76px]"; + +type CourseStopEditRowProps = { + stop: CourseStop; + isLast: boolean; + isSelected: boolean; + onToggleSelect: () => void; + onRequestDelete: () => void; +}; + +export function CourseStopEditRow({ + stop, + isLast, + isSelected, + onToggleSelect, + onRequestDelete, +}: CourseStopEditRowProps) { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: stop.id }); + + const rowStyle = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.92 : 1, + zIndex: isDragging ? 2 : 0, + }; + + return ( +
+
+ + +
+ + +
+ +
+
+
+
+ ); +} diff --git a/src/components/course-planner/DateTimeSelectionScreen.tsx b/src/components/course-planner/DateTimeSelectionScreen.tsx index ca00a3d..5f54c96 100644 --- a/src/components/course-planner/DateTimeSelectionScreen.tsx +++ b/src/components/course-planner/DateTimeSelectionScreen.tsx @@ -37,9 +37,7 @@ export function DateTimeSelectionScreen({ onClose, onConfirm, }: DateTimeSelectionScreenProps) { - const [step, setStep] = useState(() => - selectedDate !== null ? "time" : "date", - ); + const [step, setStep] = useState(() => (selectedDate !== null ? "time" : "date")); useEffect(() => { if (selectedDate !== null) return; diff --git a/src/components/place/BusinessHoursAccordion.tsx b/src/components/place/BusinessHoursAccordion.tsx index 1274ff1..f700a48 100644 --- a/src/components/place/BusinessHoursAccordion.tsx +++ b/src/components/place/BusinessHoursAccordion.tsx @@ -28,8 +28,8 @@ export function BusinessHoursAccordion({ businessHours }: BusinessHoursAccordion if (!businessHours) { return (
-

영업시간

-

정보 없음

+

영업시간

+

정보 없음

); } @@ -37,22 +37,24 @@ export function BusinessHoursAccordion({ businessHours }: BusinessHoursAccordion return (
-

영업시간

-

{buildStatusSummary(businessHours)}

+

영업시간

+

{buildStatusSummary(businessHours)}

{businessHours.holidayNotice ? ( -

{businessHours.holidayNotice}

+

+ {businessHours.holidayNotice} +

) : null}
{todayHours ? ( -
-

+

+

{todayHours.label} {todayHours.hours}

) : null} diff --git a/src/components/room/RoomAddDrawer.tsx b/src/components/room/RoomAddDrawer.tsx index 795bc71..9bdb498 100644 --- a/src/components/room/RoomAddDrawer.tsx +++ b/src/components/room/RoomAddDrawer.tsx @@ -5,7 +5,7 @@ export type RoomAddDrawerProps = { export function RoomAddDrawer({ onSelectCreate, onSelectJoin }: RoomAddDrawerProps) { return ( -
+

방 추가하기

diff --git a/src/components/ui/BottomSheet.tsx b/src/components/ui/BottomSheet.tsx index 15a7613..fc153d3 100644 --- a/src/components/ui/BottomSheet.tsx +++ b/src/components/ui/BottomSheet.tsx @@ -136,7 +136,7 @@ export function BottomSheet({ onClick={(event) => event.stopPropagation()} > {!hideHandle ? ( -
+
) : null} @@ -144,7 +144,7 @@ export function BottomSheet({
diff --git a/src/hooks/use-bottom-nav-controller.ts b/src/hooks/use-bottom-nav-controller.ts index 9e9c3ea..0c28d27 100644 --- a/src/hooks/use-bottom-nav-controller.ts +++ b/src/hooks/use-bottom-nav-controller.ts @@ -16,22 +16,30 @@ const NAV_PATH_BY_ID: Record = { const ROOM_SCOPED_NAVS: BottomNavId[] = ["list", "map", "course"]; +export type BottomNavToastPlacement = "top" | "bottom"; + +type ToastState = { + message: string; + durationMs: number; + placement: BottomNavToastPlacement; +}; + export function useBottomNavController() { const navigate = useNavigate(); const selectedRoom = useRoomSelectionStore((s) => s.selectedRoom); - const [toastMessage, setToastMessage] = useState(""); + const [toastState, setToastState] = useState(null); useEffect(() => { - if (!toastMessage) return; - const timer = window.setTimeout(() => setToastMessage(""), 1500); + if (!toastState?.message) return; + const timer = window.setTimeout(() => setToastState(null), toastState.durationMs); return () => window.clearTimeout(timer); - }, [toastMessage]); + }, [toastState]); const handleSelectBottomNav = useCallback( (id: BottomNavId) => { const needsRoom = ROOM_SCOPED_NAVS.includes(id); if (needsRoom && !selectedRoom) { - setToastMessage(ROOM_REQUIRED_TOAST); + setToastState({ message: ROOM_REQUIRED_TOAST, durationMs: 1500, placement: "bottom" }); return; } @@ -40,12 +48,16 @@ export function useBottomNavController() { [navigate, selectedRoom], ); - const showToast = useCallback((message: string) => { - setToastMessage(message); - }, []); + const showToast = useCallback( + (message: string, durationMs = 1500, placement: BottomNavToastPlacement = "bottom") => { + setToastState({ message, durationMs, placement }); + }, + [], + ); return { - toastMessage, + toastMessage: toastState?.message ?? "", + toastPlacement: toastState?.placement ?? "bottom", handleSelectBottomNav, showToast, }; diff --git a/src/pages/map/MapHomePage.tsx b/src/pages/map/MapHomePage.tsx index 56ec25b..35c3f98 100644 --- a/src/pages/map/MapHomePage.tsx +++ b/src/pages/map/MapHomePage.tsx @@ -44,7 +44,7 @@ export function MapHomePageContent({ filterDataOverride = null, }: MapHomePageContentProps): JSX.Element { const selectedRoom = useRoomSelectionStore((s) => s.selectedRoom); - const { toastMessage, handleSelectBottomNav } = useBottomNavController(); + const { toastMessage, toastPlacement, handleSelectBottomNav } = useBottomNavController(); const [friendMenuOpen, setFriendMenuOpen] = useState(false); const now = useKoreanNow(); const mapTitle = selectedRoom ? selectedRoom.name : "데이트 지도"; @@ -127,7 +127,7 @@ export function MapHomePageContent({
- + 0 ? `${nickname.trim()}님의 데이트 지도` : "데이트 지도"; - const { toastMessage, handleSelectBottomNav, showToast } = useBottomNavController(); + const { toastMessage, toastPlacement, handleSelectBottomNav, showToast } = + useBottomNavController(); const { actionRoom, openRoomActions, closeRoomActions } = useRoomActionModalHistory(); const { sortedRows, @@ -121,7 +122,7 @@ export default function RoomMainPage() { fab={} bottomNav={ <> - + } diff --git a/src/pages/tabs/CoursePlannerPage.tsx b/src/pages/tabs/CoursePlannerPage.tsx index 12f01c2..7b7ffd9 100644 --- a/src/pages/tabs/CoursePlannerPage.tsx +++ b/src/pages/tabs/CoursePlannerPage.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense, useCallback, useEffect, useState } from "react"; import { Navigate, useNavigate } from "react-router-dom"; +import type { BottomNavId } from "@/components/common/BottomNavigationBar"; import { BottomNavigationBar } from "@/components/common/BottomNavigationBar"; import { BottomNavToast } from "@/components/common/BottomNavToast"; import { @@ -9,7 +10,6 @@ import { isEndAfterStart, isHmString, } from "@/components/course-planner/course-date-time"; -import { CourseEditPanel } from "@/components/course-planner/CourseEditPanel"; import { CourseGenerationLoadingPanel } from "@/components/course-planner/CourseGenerationLoadingPanel"; import { CoursePlaceInfoPanel, @@ -25,6 +25,7 @@ import { import { DateTimeSelectionScreen } from "@/components/course-planner/DateTimeSelectionScreen"; import { RegionSelectionPanel } from "@/components/course-planner/RegionSelectionPanel"; import { MapHeader } from "@/components/map/MapHeader"; +import { PlaceDetailSheet } from "@/components/place/PlaceDetailSheet"; import { useMapSearchFilters } from "@/features/map/hooks/use-map-search-filters"; import { usePlaceFilterData } from "@/features/map/hooks/use-place-filter-data"; import { useBottomNavController } from "@/hooks/use-bottom-nav-controller"; @@ -33,7 +34,7 @@ import MyHomePage_WithDetail from "@/pages/MyHomePage_WithDetail"; import { MAP_ALL_CATEGORY_FILTER_CHIP } from "@/shared/types/map-home"; import { useRoomSelectionStore } from "@/store/room-selection-store"; -type CoursePlannerMode = "form" | "region" | "datetime" | "loading" | "result" | "detail" | "edit"; +type CoursePlannerMode = "form" | "region" | "datetime" | "loading" | "result" | "detail"; const KAKAO_MAP_APP_KEY = import.meta.env.VITE_KAKAO_MAP_APP_KEY; const KakaoMapView = lazy(() => @@ -41,14 +42,15 @@ const KakaoMapView = lazy(() => ); const mockCourses: CourseOption[] = [ - { id: "course-1", title: "코스 1", description: "캠퍼스 산책부터 감동까지" }, - { id: "course-2", title: "코스 2", description: "카페와 전시를 가볍게" }, - { id: "course-3", title: "코스 3", description: "저녁까지 이어지는 여유 코스" }, + { id: "course-1", title: "코스 1", description: "균형 있게 구성된 코스" }, + { id: "course-2", title: "코스 2", description: "릴스 좋아요 순으로 구성된 인기 코스" }, + { id: "course-3", title: "코스 3", description: "최근 등록된 장소로 구성된 코스" }, ]; const mockStops: CourseStop[] = [ { id: "hufs-seoul", + placeId: "place-1", name: "한국외국어대학교 서울캠퍼스", address: "서울 동대문구 이문로 107", category: "대학 · 캠퍼스", @@ -57,6 +59,7 @@ const mockStops: CourseStop[] = [ }, { id: "gangneung", + placeId: "place-2", name: "감동", address: "회기로 25길 10-13 1층", category: "맛집", @@ -65,6 +68,7 @@ const mockStops: CourseStop[] = [ }, { id: "oegwan-street", + placeId: "place-3", name: "사르스트 외대점", address: "회기로 23길 2", category: "카페", @@ -77,9 +81,11 @@ type CoursePlannerPageProps = { skipRoomGuard?: boolean; }; -function CourseDevMapBackground() { - const { toastMessage, handleSelectBottomNav } = useBottomNavController(); - +function CourseDevMapBackground({ + onSelectBottomNav, +}: { + onSelectBottomNav: (id: BottomNavId) => void; +}) { return (
@@ -96,8 +102,7 @@ function CourseDevMapBackground() {
- - +
); @@ -106,6 +111,8 @@ function CourseDevMapBackground() { export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlannerPageProps) { const navigate = useNavigate(); const selectedRoom = useRoomSelectionStore((s) => s.selectedRoom); + const { toastMessage, toastPlacement, handleSelectBottomNav, showToast } = + useBottomNavController(); const [mode, setMode] = useState("form"); const [regionValue, setRegionValue] = useState(""); const [draftCity, setDraftCity] = useState("서울"); @@ -114,10 +121,9 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann const [draftDate, setDraftDate] = useState(null); const [draftStartTime, setDraftStartTime] = useState(null); const [draftEndTime, setDraftEndTime] = useState(null); - const [selectedCourseId, setSelectedCourseId] = useState(mockCourses[0]?.id ?? ""); + const [selectedCourseId, setSelectedCourseId] = useState(""); const [courseTitle, setCourseTitle] = useState(mockCourses[0]?.title ?? "코스 1"); const [courseStops, setCourseStops] = useState(mockStops); - const [completionNoticeVisible, setCompletionNoticeVisible] = useState(false); const { categories, categoryNameByCode, @@ -145,22 +151,13 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann if (mode !== "loading") return; const timerId = window.setTimeout(() => { - setCompletionNoticeVisible(true); + showToast("데이트코스가 완성되었습니다", 3200); + setSelectedCourseId(""); setMode("result"); }, 900); return () => window.clearTimeout(timerId); - }, [mode]); - - useEffect(() => { - if (!completionNoticeVisible) return; - - const timerId = window.setTimeout(() => { - setCompletionNoticeVisible(false); - }, 5000); - - return () => window.clearTimeout(timerId); - }, [completionNoticeVisible]); + }, [mode, showToast]); const applyDateTimeFromDrafts = useCallback(() => { if (!draftDate) { @@ -231,7 +228,9 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann setDraftStartTime(null); setDraftEndTime(null); toggleCategory(MAP_ALL_CATEGORY_FILTER_CHIP); - setCompletionNoticeVisible(false); + setSelectedCourseId(""); + setCourseTitle(mockCourses[0]?.title ?? "코스 1"); + setCourseStops(mockStops); setMode("form"); }; @@ -248,14 +247,16 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann setMode("detail"); }; - const handleSaveCourseEdit = (nextTitle: string, nextStops: CourseStop[]) => { - setCourseTitle(nextTitle); - setCourseStops(nextStops); - setCompletionNoticeVisible(true); - setMode("detail"); + const handleSaveCourse = (nextTitle: string, nextStops: CourseStop[], fromEditMode: boolean) => { + showToast("코스가 저장되었습니다", 3200); + if (fromEditMode) { + setCourseTitle(nextTitle); + setCourseStops(nextStops); + return; + } + handleResetPlanner(); }; - const statusMessage = completionNoticeVisible ? "데이트코스가 완성되었습니다" : undefined; const placeFilterBarProps = { categories, categoryNameByCode, @@ -278,15 +279,15 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann return ( <> - {selectedRoom ? : } + {selectedRoom ? ( + + ) : ( + + )} - {statusMessage ? ( -
-
- {statusMessage} -
-
- ) : null} + + + {skipRoomGuard && !selectedRoom ? : null} {mode === "region" ? ( @@ -326,7 +327,9 @@ export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlann /> ) : null} - {mode === "loading" ? : null} + {mode === "loading" ? ( + + ) : null} {mode === "result" ? ( setMode("result")} - onEdit={() => setMode("edit")} - /> - ) : null} - - {mode === "edit" ? ( - setMode("detail")} - onSave={handleSaveCourseEdit} + onBack={() => { + setSelectedCourseId(""); + setMode("result"); + }} + onSave={handleSaveCourse} /> ) : null} diff --git a/src/pages/tabs/MyPage.tsx b/src/pages/tabs/MyPage.tsx index fad6b82..9464e85 100644 --- a/src/pages/tabs/MyPage.tsx +++ b/src/pages/tabs/MyPage.tsx @@ -3,12 +3,12 @@ import { BottomNavToast } from "@/components/common/BottomNavToast"; import { useBottomNavController } from "@/hooks/use-bottom-nav-controller"; export default function MyPage() { - const { toastMessage, handleSelectBottomNav } = useBottomNavController(); + const { toastMessage, toastPlacement, handleSelectBottomNav } = useBottomNavController(); return (
- +
); diff --git a/src/pages/tabs/PlaceListPage.tsx b/src/pages/tabs/PlaceListPage.tsx index 9ee7a2b..1a7049a 100644 --- a/src/pages/tabs/PlaceListPage.tsx +++ b/src/pages/tabs/PlaceListPage.tsx @@ -7,7 +7,7 @@ import { useRoomSelectionStore } from "@/store/room-selection-store"; export default function PlaceListPage() { const selectedRoom = useRoomSelectionStore((s) => s.selectedRoom); - const { toastMessage, handleSelectBottomNav } = useBottomNavController(); + const { toastMessage, toastPlacement, handleSelectBottomNav } = useBottomNavController(); if (!selectedRoom) { return ; @@ -16,7 +16,7 @@ export default function PlaceListPage() { return (
- +
);