diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 8750823..eb7c8e3 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -19,6 +19,7 @@ import TermsAgreementPage from "@/pages/onboarding/TermsAgreementPage"; import RoomLinkCandidatesPage from "@/pages/rooms/RoomLinkCandidatesPage"; import RoomPlaceFromLinkPage from "@/pages/rooms/RoomPlaceFromLinkPage"; import RoomPlaceSearchPage from "@/pages/rooms/RoomPlaceSearchPage"; +import CoursePlannerPagePreview from "@/pages/tabs/CoursePlannerPage"; const MapHomePage = lazy(() => import("@/pages/MapHomePage")); const RoomMainPage = lazy(() => import("@/pages/room/RoomMainPage")); @@ -37,6 +38,7 @@ export const router = createBrowserRouter([ { path: "login", element: }, { path: "privacys", element: }, { path: "terms", element: }, + { path: "dev/course", element: }, { path: "auth/callback", element: , diff --git a/src/components/course-planner/CourseGenerationLoadingPanel.tsx b/src/components/course-planner/CourseGenerationLoadingPanel.tsx index b866a3e..2373546 100644 --- a/src/components/course-planner/CourseGenerationLoadingPanel.tsx +++ b/src/components/course-planner/CourseGenerationLoadingPanel.tsx @@ -1,24 +1,27 @@ -import { Loader2 } from "lucide-react"; +import { BrandMarkerLoader } from "@/components/ui/BrandMarkerLoader"; +import { cn } from "@/lib/utils"; type CourseGenerationLoadingPanelProps = { - /** 현재 컨텍스트 방 이름 (`useRoomSelectionStore`) */ roomName: string; + className?: string; }; -export function CourseGenerationLoadingPanel({ roomName }: CourseGenerationLoadingPanelProps) { +export function CourseGenerationLoadingPanel({ + roomName, + className, +}: CourseGenerationLoadingPanelProps) { const label = roomName.trim() || "방"; return ( -
-
-

- {label} - 맞춤 데이트 코스를 생성하고 있어요 +

+
+
+ +
+

+ {label}의 장소들로 + 데이트 코스를 생성하고 있어요

-
); diff --git a/src/components/course-planner/CoursePlaceAddSheet.tsx b/src/components/course-planner/CoursePlaceAddSheet.tsx index c2d3336..90def9e 100644 --- a/src/components/course-planner/CoursePlaceAddSheet.tsx +++ b/src/components/course-planner/CoursePlaceAddSheet.tsx @@ -1,68 +1,96 @@ import { useMemo, useState } from "react"; import { createPortal } from "react-dom"; -import { EditPlaceResultCard } from "@/components/link-place/EditPlaceResultCard"; -import { PlaceFlowSearchEmptyRow } from "@/components/place-flow/PlaceFlowSearchEmptyRow"; -import { PlaceFlowSearchFieldRow } from "@/components/place-flow/PlaceFlowSearchFieldRow"; -import { BottomSheet } from "@/components/ui/BottomSheet"; -import { PillButton } from "@/components/ui/PillButton"; -import { PLACE_FLOW_COPY } from "@/features/place-flow/place-flow-copy"; -import { PROMPT_FLOW_LIST_TOP_BORDER_CLASS } from "@/features/place-flow/prompt-flow-layout"; -import { SAVED_PLACE_MOCKS } from "@/shared/mocks/place-mocks"; +import { PlaceSearchMapSheet } from "@/components/place-flow/PlaceSearchMapSheet"; +import type { PlaceCandidate } from "@/features/place-candidates"; +import { + canSubmitPlaceCandidate, + placeCandidateToSavedPlace, + usePlaceCandidates, +} from "@/features/place-candidates"; +import { cn } from "@/lib/utils"; import type { SavedPlace } from "@/shared/types/map-home"; +import { + FULLSCREEN_FLOW_PANEL_CLASSES, + FULLSCREEN_FLOW_ROUTE_OUTER_CLASSES, +} from "@/shared/ui/fullscreen-flow-layout"; type CoursePlaceAddSheetProps = { open: boolean; + roomId?: string | null; excludedPlaceIds: string[]; onClose: () => void; onConfirm: (place: SavedPlace) => void; }; -function findPlaceMatches(keyword: string): SavedPlace[] { - const trimmedKeyword = keyword.trim(); - if (!trimmedKeyword) { - return []; - } - - return SAVED_PLACE_MOCKS.filter( - (place) => place.name.includes(trimmedKeyword) || place.address.includes(trimmedKeyword), - ); -} +const EMPTY_PLACE_CANDIDATES: PlaceCandidate[] = []; export function CoursePlaceAddSheet({ open, + roomId = null, excludedPlaceIds, onClose, onConfirm, }: CoursePlaceAddSheetProps) { const [keyword, setKeyword] = useState(""); + const [submittedKeyword, setSubmittedKeyword] = useState(""); const [selectedPlaceId, setSelectedPlaceId] = useState(null); const excludedPlaceIdSet = useMemo(() => new Set(excludedPlaceIds), [excludedPlaceIds]); - - const searchResults = useMemo(() => findPlaceMatches(keyword), [keyword]); - const availableResults = useMemo( - () => searchResults.filter((place) => !excludedPlaceIdSet.has(place.id)), - [excludedPlaceIdSet, searchResults], - ); - const selectedPlace = availableResults.find((place) => place.id === selectedPlaceId) ?? null; const trimmedKeyword = keyword.trim(); - const canSearch = trimmedKeyword.length > 0; - const canConfirm = selectedPlace != null; + const submittedTrimmedKeyword = submittedKeyword.trim(); + const isSubmittedKeywordCurrent = submittedTrimmedKeyword === trimmedKeyword; + + const placeCandidatesQuery = usePlaceCandidates({ + roomId: roomId ?? null, + params: { + keyword: submittedTrimmedKeyword, + limit: 10, + }, + enabled: open && Boolean(roomId) && submittedTrimmedKeyword.length > 0, + }); + const placeCandidates = placeCandidatesQuery.data ?? EMPTY_PLACE_CANDIDATES; + + const availableResults = useMemo(() => { + if (!isSubmittedKeywordCurrent || submittedTrimmedKeyword.length === 0) { + return []; + } + + return placeCandidates + .map((place, index) => ({ + candidate: place, + savedPlace: placeCandidateToSavedPlace(place, index), + })) + .filter(({ candidate, savedPlace }) => { + if (!canSubmitPlaceCandidate(candidate)) { + return false; + } + + return ( + !excludedPlaceIdSet.has(savedPlace.id) && + !(savedPlace.kakaoPlaceId && excludedPlaceIdSet.has(savedPlace.kakaoPlaceId)) + ); + }); + }, [excludedPlaceIdSet, isSubmittedKeywordCurrent, placeCandidates, submittedTrimmedKeyword]); + + const selectedPlace = + availableResults.find(({ savedPlace }) => savedPlace.id === selectedPlaceId)?.savedPlace ?? + null; + const searchResults = availableResults.map(({ savedPlace }) => savedPlace); const resetAndClose = () => { setKeyword(""); + setSubmittedKeyword(""); setSelectedPlaceId(null); onClose(); }; const handleSubmitSearch = () => { - if (!canSearch) { + if (trimmedKeyword.length === 0) { return; } - if (availableResults.length === 1) { - setSelectedPlaceId(availableResults[0].id); - } + setSubmittedKeyword(trimmedKeyword); + setSelectedPlaceId(null); }; const handleConfirm = () => { @@ -72,86 +100,40 @@ export function CoursePlaceAddSheet({ onConfirm(selectedPlace); setKeyword(""); + setSubmittedKeyword(""); setSelectedPlaceId(null); }; - const hasOnlyDuplicateMatches = - trimmedKeyword.length > 0 && searchResults.length > 0 && availableResults.length === 0; + if (!open) { + return null; + } return createPortal( - -
-
-

- 장소 추가하기 -

-

코스에 추가할 장소를 검색해 주세요

-
- -
- { - setKeyword(next); - setSelectedPlaceId(null); - }} - placeholder={PLACE_FLOW_COPY.searchPlaceholder} - searchButtonLabel={PLACE_FLOW_COPY.searchButton} - onSubmitSearch={handleSubmitSearch} - searchButtonDisabled={!canSearch} - /> -
- -
- {trimmedKeyword ? ( -
    - {availableResults.length === 0 ? ( - - ) : ( - availableResults.map((place) => ( - setSelectedPlaceId(place.id)} - /> - )) - )} -
- ) : null} -
- -
-
- - 취소 - -
-
- - 확인 - -
-
+
+
+ 0 && isSubmittedKeywordCurrent} + canConfirm={selectedPlace != null} + onKeywordChange={(next) => { + setKeyword(next); + setSelectedPlaceId(null); + }} + onSubmitSearch={handleSubmitSearch} + onSelectPlace={setSelectedPlaceId} + onClearSelectedPlace={() => setSelectedPlaceId(null)} + onCancel={resetAndClose} + onConfirm={handleConfirm} + />
- , +
, document.body, ); } diff --git a/src/components/course-planner/CoursePlaceInfoPanel.tsx b/src/components/course-planner/CoursePlaceInfoPanel.tsx index b1136fd..bd2c956 100644 --- a/src/components/course-planner/CoursePlaceInfoPanel.tsx +++ b/src/components/course-planner/CoursePlaceInfoPanel.tsx @@ -34,18 +34,22 @@ type SaveConfirmKind = "create" | "edit"; type CoursePlaceInfoPanelProps = { courseTitle: string; stops: CourseStop[]; + roomId?: string | null; onBack: () => void; onSave: (payload: CourseSavePayload) => void; /** true면 조회 모드에서 「데이트 코스 저장하기」 버튼을 숨김 — 이미 저장된 코스(마이 페이지 등) */ hideNewCourseSaveButton?: boolean; + className?: string; }; export function CoursePlaceInfoPanel({ courseTitle, stops, + roomId = null, onBack, onSave, hideNewCourseSaveButton = false, + className, }: CoursePlaceInfoPanelProps) { const openDetail = usePlaceDetailStore((s) => s.openDetail); @@ -135,6 +139,7 @@ export function CoursePlaceInfoPanel({ hideNewCourseSaveButton && !isEditing ? "pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))]" : "pb-0", + className, )} > {isEditing ? ( @@ -309,6 +314,7 @@ export function CoursePlaceInfoPanel({ stop.placeId)} onClose={() => setIsAddPlaceOpen(false)} onConfirm={handleAddPlace} diff --git a/src/components/course-planner/CoursePlannerActions.tsx b/src/components/course-planner/CoursePlannerActions.tsx index 2b5a78b..d1fbfd0 100644 --- a/src/components/course-planner/CoursePlannerActions.tsx +++ b/src/components/course-planner/CoursePlannerActions.tsx @@ -6,15 +6,17 @@ type CoursePlannerActionsProps = { canGenerate: boolean; onGenerate: () => void; onReset: () => void; + className?: string; }; export function CoursePlannerActions({ canGenerate, onGenerate, onReset, + className, }: CoursePlannerActionsProps) { return ( -
+
+ ) : null} +
+ ) : ( +
+ {courseOrders.map((order, index) => { + const selectedCategory = categoryOptions.find((type) => type.id === order.category); + + return ( +
+
+
+ + {index + 1} + +
+

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

+

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

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

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

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

맞춤 데이트 코스 확인하기

diff --git a/src/components/course-planner/DateTimeSelectionScreen.tsx b/src/components/course-planner/DateTimeSelectionScreen.tsx index 5f54c96..f1e9a14 100644 --- a/src/components/course-planner/DateTimeSelectionScreen.tsx +++ b/src/components/course-planner/DateTimeSelectionScreen.tsx @@ -14,6 +14,7 @@ type DateTimeSelectionScreenProps = { selectedDate: string | null; selectedStartTime: string | null; selectedEndTime: string | null; + className?: string; onSelectDate: (date: string) => void; onSelectStartTime: (time: string | null) => void; onSelectEndTime: (time: string | null) => void; @@ -31,6 +32,7 @@ export function DateTimeSelectionScreen({ selectedDate, selectedStartTime, selectedEndTime, + className, onSelectDate, onSelectStartTime, onSelectEndTime, @@ -61,7 +63,7 @@ export function DateTimeSelectionScreen({ const headerTitle = step === "date" ? "날짜 선택" : "시간 설정"; return ( -
+
{step === "time" ? ( + ); +} diff --git a/src/components/course-planner/RegionSelectionPanel.tsx b/src/components/course-planner/RegionSelectionPanel.tsx index 6a81db5..b230636 100644 --- a/src/components/course-planner/RegionSelectionPanel.tsx +++ b/src/components/course-planner/RegionSelectionPanel.tsx @@ -19,6 +19,7 @@ type RegionSelectionPanelProps = { cityErrorMessage?: string | null; districtErrorMessage?: string | null; searchKeyword?: string; + className?: string; onSearchKeywordChange?: (keyword: string) => void; onSelectCity: (city: string, option?: RegionSelectionOption) => void; onSelectDistrict: (district: string, option?: RegionSelectionOption) => void; @@ -36,6 +37,7 @@ export function RegionSelectionPanel({ cityErrorMessage = null, districtErrorMessage = null, searchKeyword, + className, onSearchKeywordChange, onSelectCity, onSelectDistrict, @@ -108,7 +110,7 @@ export function RegionSelectionPanel({ }; return ( -
+

지역설정