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/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/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/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..11264e3 --- /dev/null +++ b/src/components/course-planner/CourseConfirmModal.tsx @@ -0,0 +1,71 @@ +import { createPortal } from "react-dom"; + +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 createPortal( + +
+

{title}

+

{description}

+
+
+ + +
+
, + document.body, + ); +} diff --git a/src/components/course-planner/CourseGenerationLoadingPanel.tsx b/src/components/course-planner/CourseGenerationLoadingPanel.tsx new file mode 100644 index 0000000..cb02f77 --- /dev/null +++ b/src/components/course-planner/CourseGenerationLoadingPanel.tsx @@ -0,0 +1,25 @@ +import { Loader2 } from "lucide-react"; + +type CourseGenerationLoadingPanelProps = { + /** 현재 컨텍스트 방 이름 (`useRoomSelectionStore`) */ + roomName: string; +}; + +export function CourseGenerationLoadingPanel({ roomName }: CourseGenerationLoadingPanelProps) { + const label = roomName.trim() || "방"; + + return ( +
+
+

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

+ +
+
+ ); +} diff --git a/src/components/course-planner/CoursePlaceInfoPanel.tsx b/src/components/course-planner/CoursePlaceInfoPanel.tsx new file mode 100644 index 0000000..debe5b3 --- /dev/null +++ b/src/components/course-planner/CoursePlaceInfoPanel.tsx @@ -0,0 +1,264 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { ChevronLeft, MapPin, Pencil, PersonStanding } from "lucide-react"; +import { useCallback, useState } from "react"; + +import { CourseConfirmModal } from "@/components/course-planner/CourseConfirmModal"; +import { CourseStopEditRow } from "@/components/course-planner/CourseStopEditRow"; +import { cn } from "@/lib/utils"; +import { usePlaceDetailStore } from "@/store/placeDetailStore"; + +export type CourseStop = { + id: string; + /** 지도 저장 장소 ID (`SAVED_PLACE_MOCKS`) — 핀 탭과 동일한 장소 정보 시트 연동 */ + placeId: string; + name: string; + address: string; + category: string; + walkingTime: string; + hours: string; +}; + +type CoursePlaceInfoPanelProps = { + courseTitle: string; + stops: CourseStop[]; + onBack: () => void; + /** `fromEditMode`: 편집 후 저장이면 상위에서 상세 화면만 갱신, 아니면 신규 저장 플로우(예: 플래너 초기화) */ + onSave: (nextTitle: string, nextStops: CourseStop[], fromEditMode: boolean) => void; +}; + +export function CoursePlaceInfoPanel({ + courseTitle, + stops, + onBack, + onSave, +}: CoursePlaceInfoPanelProps) { + 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 ( +
+
+ + +
+ {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.name}

+

{stop.address}

+ + + {!isLast ? ( +
+ + {stop.walkingTime} +
+ ) : null} +
+
+ ); + }) + )} +
+ + {isEditing ? ( +
+ + +
+ ) : ( + + )} + + setIsSaveConfirmOpen(false)} + onConfirm={handleConfirmSave} + /> +
+ ); +} 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 new file mode 100644 index 0000000..228c1b6 --- /dev/null +++ b/src/components/course-planner/CoursePlannerActions.tsx @@ -0,0 +1,40 @@ +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/CoursePlannerBottomSheet.tsx b/src/components/course-planner/CoursePlannerBottomSheet.tsx new file mode 100644 index 0000000..498a613 --- /dev/null +++ b/src/components/course-planner/CoursePlannerBottomSheet.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; + +import { BottomSheet } from "@/components/ui/BottomSheet"; + +type CoursePlannerBottomSheetProps = { + open: boolean; + onClose: () => void; + children: ReactNode; +}; + +export function CoursePlannerBottomSheet({ + open, + onClose, + children, +}: CoursePlannerBottomSheetProps) { + return ( + + {children} + + ); +} diff --git a/src/components/course-planner/CoursePlannerField.tsx b/src/components/course-planner/CoursePlannerField.tsx new file mode 100644 index 0000000..8e87a40 --- /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..f2b8396 --- /dev/null +++ b/src/components/course-planner/CoursePlannerMapPreview.tsx @@ -0,0 +1,17 @@ +type CoursePlannerMapPreviewProps = { + statusMessage?: string; +}; + +export function CoursePlannerMapPreview({ statusMessage }: CoursePlannerMapPreviewProps) { + return ( +
+ {statusMessage ? ( +
+
+ {statusMessage} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/course-planner/CoursePlannerPanel.tsx b/src/components/course-planner/CoursePlannerPanel.tsx new file mode 100644 index 0000000..b80bbe0 --- /dev/null +++ b/src/components/course-planner/CoursePlannerPanel.tsx @@ -0,0 +1,61 @@ +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 type { MapFilterBarProps } from "@/components/map/filters/map-filter-bar-props"; + +type CoursePlannerPanelProps = { + regionValue: string; + dateTimeValue: string; + canGenerate: boolean; + placeFilterBarProps: MapFilterBarProps; + onOpenRegionSelect: () => void; + onOpenDateTimeSelect: () => void; + onGenerate: () => void; + onReset: () => void; +}; + +export function CoursePlannerPanel({ + regionValue, + dateTimeValue, + canGenerate, + placeFilterBarProps, + onOpenRegionSelect, + onOpenDateTimeSelect, + onGenerate, + onReset, +}: CoursePlannerPanelProps) { + return ( +
+

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

+ +
+ + + } + onClick={onOpenDateTimeSelect} + /> + +
+ 장소 종류 + +
+
+ + +
+ ); +} diff --git a/src/components/course-planner/CourseResultPanel.tsx b/src/components/course-planner/CourseResultPanel.tsx new file mode 100644 index 0000000..b7b46b7 --- /dev/null +++ b/src/components/course-planner/CourseResultPanel.tsx @@ -0,0 +1,67 @@ +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/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/DateTimeSelectionPanel.tsx b/src/components/course-planner/DateTimeSelectionPanel.tsx new file mode 100644 index 0000000..b5dd183 --- /dev/null +++ b/src/components/course-planner/DateTimeSelectionPanel.tsx @@ -0,0 +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"; + +const weekdayLabels = ["일", "월", "화", "수", "목", "금", "토"]; + +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; +}; + +/** 날짜만 선택하는 캘린더 카드 */ +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}월`; + + const moveMonth = (offset: number) => { + setVisibleMonth((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1)); + }; + + return ( +
+
+ + +
+ {monthLabel} +
+ + +
+ +
+
+ {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 DateTimeWheelsPanelProps = { + selectedDate: string | null; + selectedStartTime: string | null; + selectedEndTime: string | null; + onSelectStartTime: (time: string | null) => void; + onSelectEndTime: (time: string | null) => void; +}; + +/** 시작·종료 시간 휠 (날짜가 정해진 뒤 단계 화면에서만 사용) */ +export function DateTimeWheelsPanel({ + selectedDate, + selectedStartTime, + selectedEndTime, + onSelectStartTime, + onSelectEndTime, +}: DateTimeWheelsPanelProps) { + const showStartWheels = selectedDate !== null; + const showEndWheels = showStartWheels && isHmString(selectedStartTime); + + if (!showStartWheels) return null; + + 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..5f54c96 --- /dev/null +++ b/src/components/course-planner/DateTimeSelectionScreen.tsx @@ -0,0 +1,128 @@ +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/RegionSelectionPanel.tsx b/src/components/course-planner/RegionSelectionPanel.tsx new file mode 100644 index 0000000..c96df54 --- /dev/null +++ b/src/components/course-planner/RegionSelectionPanel.tsx @@ -0,0 +1,121 @@ +import { X } from "lucide-react"; + +import { SearchField } from "@/components/common/SearchField"; +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["서울"]; + + return ( +
+
+

지역설정

+ +
+ + + +
+
+
+ 시/도 +
+
+ {cities.map((city) => { + const selected = city === selectedCity; + return ( + + ); + })} +
+
+ +
+
+ 시/구/군 +
+
+ {districts.map((district) => { + const selected = district === selectedDistrict; + return ( + + ); + })} +
+
+
+ + +
+ ); +} 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/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 0af9b89..fc153d3 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); @@ -131,14 +136,17 @@ export function BottomSheet({ onClick={(event) => event.stopPropagation()} > {!hideHandle ? ( -
+
) : null}
{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/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/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/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 f7d8b8d..7b7ffd9 100644 --- a/src/pages/tabs/CoursePlannerPage.tsx +++ b/src/pages/tabs/CoursePlannerPage.tsx @@ -1,23 +1,356 @@ -import { Navigate } from "react-router-dom"; +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 { + type DateTimeSelection, + getDateTimeDisplayValue, + isEndAfterStart, + isHmString, +} from "@/components/course-planner/course-date-time"; +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 } from "@/components/course-planner/CoursePlannerPanel"; +import { + type CourseOption, + CourseResultPanel, +} from "@/components/course-planner/CourseResultPanel"; +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"; +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"; -export default function CoursePlannerPage() { +type CoursePlannerMode = "form" | "region" | "datetime" | "loading" | "result" | "detail"; + +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: "릴스 좋아요 순으로 구성된 인기 코스" }, + { id: "course-3", title: "코스 3", description: "최근 등록된 장소로 구성된 코스" }, +]; + +const mockStops: CourseStop[] = [ + { + id: "hufs-seoul", + placeId: "place-1", + name: "한국외국어대학교 서울캠퍼스", + address: "서울 동대문구 이문로 107", + category: "대학 · 캠퍼스", + walkingTime: "도보 10분", + hours: "10:40 ~ 19:30", + }, + { + id: "gangneung", + placeId: "place-2", + name: "감동", + address: "회기로 25길 10-13 1층", + category: "맛집", + walkingTime: "도보 8분", + hours: "11:30 ~ 21:00", + }, + { + id: "oegwan-street", + placeId: "place-3", + name: "사르스트 외대점", + address: "회기로 23길 2", + category: "카페", + walkingTime: "도보 7분", + hours: "12:00 ~ 22:00", + }, +]; + +type CoursePlannerPageProps = { + skipRoomGuard?: boolean; +}; + +function CourseDevMapBackground({ + onSelectBottomNav, +}: { + onSelectBottomNav: (id: BottomNavId) => void; +}) { + return ( +
+ + +
+ }> + + +
+ +
+ +
+
+ ); +} + +export default function CoursePlannerPage({ skipRoomGuard = false }: CoursePlannerPageProps) { + const navigate = useNavigate(); const selectedRoom = useRoomSelectionStore((s) => s.selectedRoom); - const { toastMessage, handleSelectBottomNav } = useBottomNavController(); + const { toastMessage, toastPlacement, handleSelectBottomNav, showToast } = + 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(null); + const [draftStartTime, setDraftStartTime] = useState(null); + const [draftEndTime, setDraftEndTime] = useState(null); + const [selectedCourseId, setSelectedCourseId] = useState(""); + const [courseTitle, setCourseTitle] = useState(mockCourses[0]?.title ?? "코스 1"); + const [courseStops, setCourseStops] = useState(mockStops); + 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; + + const timerId = window.setTimeout(() => { + showToast("데이트코스가 완성되었습니다", 3200); + setSelectedCourseId(""); + setMode("result"); + }, 900); + + return () => window.clearTimeout(timerId); + }, [mode, showToast]); + + const applyDateTimeFromDrafts = useCallback(() => { + if (!draftDate) { + setDateTimeValue(null); + return; + } + + const weekday = new Date(draftDate.replaceAll(".", "/")) + .toLocaleDateString("ko-KR", { weekday: "short" }) + .replace("요일", ""); + + setDateTimeValue({ + date: draftDate, + weekday, + startTime: draftStartTime, + endTime: draftEndTime, + }); + }, [draftDate, draftStartTime, draftEndTime]); + + const handleOpenDateTimeSelect = useCallback(() => { + closeTagPanel(); + setMode("datetime"); + }, [closeTagPanel]); + + const handleCloseDateTimeScreen = useCallback(() => { + setMode("form"); + }, []); - if (!selectedRoom) { + 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 handleSelectCity = (city: string) => { + setDraftCity(city); + setDraftDistrict("전체"); + }; + + const handleConfirmRegion = () => { + setRegionValue(draftDistrict === "전체" ? draftCity : `${draftCity} ${draftDistrict}`); + setMode("form"); + }; + + const handleResetPlanner = () => { + setRegionValue(""); + setDraftCity("서울"); + setDraftDistrict("강남구"); + setDateTimeValue(null); + setDraftDate(null); + setDraftStartTime(null); + setDraftEndTime(null); + toggleCategory(MAP_ALL_CATEGORY_FILTER_CHIP); + setSelectedCourseId(""); + setCourseTitle(mockCourses[0]?.title ?? "코스 1"); + setCourseStops(mockStops); + 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 handleSaveCourse = (nextTitle: string, nextStops: CourseStop[], fromEditMode: boolean) => { + showToast("코스가 저장되었습니다", 3200); + if (fromEditMode) { + setCourseTitle(nextTitle); + setCourseStops(nextStops); + return; + } + handleResetPlanner(); + }; + + 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 ? ( + + ) : ( + + )} + + + + {skipRoomGuard && !selectedRoom ? : null} + + + {mode === "region" ? ( + setMode("form")} + onConfirm={handleConfirmRegion} + /> + ) : null} + + {mode === "datetime" ? ( + + ) : null} + + {mode === "form" ? ( + setMode("region")} + onOpenDateTimeSelect={handleOpenDateTimeSelect} + onGenerate={handleGenerateCourse} + onReset={handleResetPlanner} + /> + ) : null} + + {mode === "loading" ? ( + + ) : null} + + {mode === "result" ? ( + + ) : null} + + {mode === "detail" ? ( + { + 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 (
- +
);