From 395ff83414f658371383133977bc295e976e2d39 Mon Sep 17 00:00:00 2001 From: bong Date: Sat, 19 Sep 2026 19:23:30 +0900 Subject: [PATCH 1/2] fix: design update --- src/app/(app)/trips/new/date/page.tsx | 130 +++++---- src/app/(app)/trips/new/places/page.tsx | 218 ++++++++++---- .../(app)/trips/new/places/search/page.tsx | 26 +- src/app/(app)/trips/new/step3/page.tsx | 2 +- src/components/sheet/DateRangeCalendar.tsx | 94 +++++-- src/components/sheet/LocationPickerSheet.tsx | 21 +- src/components/trip/PlacesEmptyAnimation.tsx | 127 +++++++++ src/components/trip/QuestionStep.tsx | 265 +++++++++++++----- src/components/ui/StepProgress.tsx | 17 +- 9 files changed, 678 insertions(+), 222 deletions(-) create mode 100644 src/components/trip/PlacesEmptyAnimation.tsx diff --git a/src/app/(app)/trips/new/date/page.tsx b/src/app/(app)/trips/new/date/page.tsx index 68c06a2..c0433f5 100644 --- a/src/app/(app)/trips/new/date/page.tsx +++ b/src/app/(app)/trips/new/date/page.tsx @@ -1,9 +1,9 @@ "use client"; +import Image from "next/image"; import { useState } from "react"; import { useRouter } from "next/navigation"; -import { MapPin } from "lucide-react"; -import AppHeader from "@/components/layout/AppHeader"; +import { CalendarDays, ChevronLeft, MapPin } from "lucide-react"; import DateRangeCalendar from "@/components/sheet/DateRangeCalendar"; import LocationPickerSheet from "@/components/sheet/LocationPickerSheet"; import PageFade from "@/components/ui/PageFade"; @@ -43,22 +43,22 @@ function LocationField({ type="button" aria-haspopup="dialog" onClick={onOpen} - className="flex w-full items-center gap-3 rounded-2xl border-2 border-zinc-200 bg-white p-3 text-left transition-colors hover:border-[#2E7DF2]" + className="flex h-16 w-full items-center gap-3 rounded-[1.2rem] border border-[#d7e1ee] bg-white px-3.5 text-left shadow-[0_6px_18px_rgba(35,75,123,0.05)] transition-colors hover:border-[#8dbbf8] active:bg-[#f7faff]" > - - + + - 출발지 + 출발지 {value?.name ?? "역, 터미널 또는 장소를 검색하세요"} - + › @@ -70,10 +70,10 @@ export default function TripDatePage() { const { draft, updateDraft } = useTripDraft(); const [start, setStart] = useState(() => parseDate(draft.startDate)); const [end, setEnd] = useState(() => parseDate(draft.endDate)); - const [startLocation, setStartLocation] = useState( - draft.startLocation, + const [startLocation, setStartLocation] = useState( + draft.startLocation ?? null, ); - const [picker, setPicker] = useState<"start" | null>(null); + const [pickerOpen, setPickerOpen] = useState(false); const [error, setError] = useState(null); const nights = start && end ? differenceInDays(start, end) : 0; @@ -116,72 +116,106 @@ export default function TripDatePage() { } return ( - - + +
+ +
+ +

+ 여행 날짜 +

+
-
+
-

언제 떠나시나요?

-

출발일과 도착일을 선택해 주세요

+

+ 언제 떠나시나요? +

+

+ 출발일과 도착일을 선택해 주세요 +

-
+
-

가는 날

-

{start ? formatPoint(start) : "-"}

+
+

가는 날

+

+ {start ? formatPoint(start) : "날짜 선택"} +

+
+
+
-

오는 날

-

{end ? formatPoint(end) : "-"}

+
+

오는 날

+

+ {end ? formatPoint(end) : "날짜 선택"} +

+
+
- - - {start && end && ( - <> - {tooLong ? ( -

- 여행 기간은 최대 4일까지 선택할 수 있어요 -

- ) : ( -

- {nights === 0 ? "당일 여행 선택됨" : `${nights}박 ${nights + 1}일 선택됨`} -

- )} +
+ +
-
-

출발지

- setPicker("start")} /> -
- + {error && ( +

+ {error} +

)} - {error &&

{error}

} + {start && end && !tooLong && ( +
+

출발지

+ setPickerOpen(true)} /> +
+ )}
- {picker && ( + {pickerOpen && ( setPicker(null)} + onClose={() => setPickerOpen(false)} onSelect={setStartLocation} /> )} diff --git a/src/app/(app)/trips/new/places/page.tsx b/src/app/(app)/trips/new/places/page.tsx index 989b986..597f982 100644 --- a/src/app/(app)/trips/new/places/page.tsx +++ b/src/app/(app)/trips/new/places/page.tsx @@ -1,90 +1,204 @@ "use client"; -import { useEffect, useState } from "react"; -import Lottie from "lottie-react"; +import { AnimatePresence, motion } from "framer-motion"; import Image from "next/image"; import { useRouter } from "next/navigation"; import Link from "next/link"; +import { ChevronLeft, MapPin, Search, Trash2 } from "lucide-react"; +import PlacesEmptyAnimation from "@/components/trip/PlacesEmptyAnimation"; import PageFade from "@/components/ui/PageFade"; -import searchIcon from "@/assets/icons/search-256.png"; +import { useTripDraft } from "@/store/trip-draft"; +import type { PlaceSearchItem } from "@/types/api/place"; + +const QUICK_SEARCHES = ["광안리", "해운대", "전포 카페", "감천문화마을"]; + +function MiniPlacePin() { + return ( + + ); +} + +function isSamePlace(left: PlaceSearchItem, right: PlaceSearchItem) { + if (left.placeId !== null && right.placeId !== null) return left.placeId === right.placeId; + return left.source === right.source && left.externalId === right.externalId; +} + +function selectionKey(place: PlaceSearchItem) { + return place.placeId !== null + ? `place:${place.placeId}` + : `${place.source}:${place.externalId}`; +} export default function TripPlacesIntroPage() { const router = useRouter(); - const [travelIconData, setTravelIconData] = useState(null); + const { draft, updateDraft } = useTripDraft(); + const selectedPlaces = draft.selectedPlaces; - useEffect(() => { - let cancelled = false; - fetch("/animation/map-icons.json") - .then((r) => r.json()) - .then((data) => { - if (!cancelled) setTravelIconData(data); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, []); + function removePlace(place: PlaceSearchItem) { + const nextPlaces = selectedPlaces.filter((item) => !isSamePlace(item, place)); + updateDraft({ + selectedPlaces: nextPlaces, + mustVisitPlaceIds: nextPlaces.flatMap((item) => + item.placeId === null ? [] : [item.placeId], + ), + }); + } return ( - -
+ +
+ +
-

가고 싶은 곳

- +
+

가고 싶은 곳

+ + 선택 사항 + +
-
-
-
-
+
+ + + 장소를 검색해 담아보세요 + - - - 장소를 검색해 담아보세요 - +
+ + {selectedPlaces.length === 0 ? ( + + +
+

+ 검색해서 장소를 추가해 보세요 +

+

+ 추가한 장소를 중심으로 동선을 만들어요 +

+
+
+ ) : ( + + + {selectedPlaces.map((place, index) => ( + + + {index + 1} + + {place.primaryImageUrl ? ( + // 외부 장소 이미지 도메인이 API 응답마다 달라 일반 img로 표시한다. + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + + + )} + + {place.name} + + {[place.categoryLabel, place.address].filter(Boolean).join(" · ")} + + + + + ))} + + + )} +
+
- {travelIconData && ( - - )} +
+ {QUICK_SEARCHES.map((keyword) => ( + + + {keyword} + + ))}
-
-

- 선택 · 원하면 건너뛰어도 돼요 -

-

- 꼭 가고 싶은 -
- 곳이 있나요? -

-

- 먼저 담아두면 그 장소를 중심으로 -
- 동선까지 맞춰 일정을 짜드려요 +

+

+ 꼭 가고 싶은 곳이 있나요? +

+

+ 먼저 담아두면 그 장소를 중심으로 동선까지 맞춰 일정을 짜드려요

-
+
가고 싶은 곳 담기 건너뛰고 AI에게 맡기기 diff --git a/src/app/(app)/trips/new/places/search/page.tsx b/src/app/(app)/trips/new/places/search/page.tsx index 7105ec7..3ce0fb7 100644 --- a/src/app/(app)/trips/new/places/search/page.tsx +++ b/src/app/(app)/trips/new/places/search/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { Bike, @@ -16,7 +16,7 @@ import { } from "lucide-react"; import dynamic from "next/dynamic"; import Image from "next/image"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { renderToStaticMarkup } from "react-dom/server"; import type { DistrictSelection } from "@/components/map/BusanDistrictPicker"; import MapTiler3D, { type MapTiler3DMarker } from "@/components/map/MapTiler3D"; @@ -139,11 +139,13 @@ function PlaceThumbnail({ place }: { place: PlaceSearchItem }) { ); } -export default function AiPlacesSearchPage() { +function AiPlacesSearchContent() { const router = useRouter(); + const searchParams = useSearchParams(); const { draft, updateDraft } = useTripDraft(); + const initialQuery = searchParams.get("q")?.trim() ?? ""; - const [query, setQuery] = useState(""); + const [query, setQuery] = useState(initialQuery); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [resolvingKey, setResolvingKey] = useState(null); @@ -152,7 +154,7 @@ export default function AiPlacesSearchPage() { const [picked, setPicked] = useState(draft.selectedPlaces); const [mapCenter, setMapCenter] = useState<{ lat: number; lng: number } | undefined>(); const [activePlace, setActivePlace] = useState(null); - const [showResults, setShowResults] = useState(false); + const [showResults, setShowResults] = useState(Boolean(initialQuery)); const [mapView, setMapView] = useState("2d"); const [mode, setMode] = useState<"search" | "region" | "wishlist">("search"); // 찜한 장소는 탭을 처음 열 때 한 번 읽는다. 담는 것은 사용자가 고른 장소뿐이다. @@ -747,6 +749,20 @@ export default function AiPlacesSearchPage() { ); } +export default function AiPlacesSearchPage() { + return ( + + +
+ } + > + + + ); +} + function WishlistPicker({ items, failed, diff --git a/src/app/(app)/trips/new/step3/page.tsx b/src/app/(app)/trips/new/step3/page.tsx index ed1692f..069b27b 100644 --- a/src/app/(app)/trips/new/step3/page.tsx +++ b/src/app/(app)/trips/new/step3/page.tsx @@ -8,7 +8,7 @@ export default function TripPreferenceStepThreePage() { total={3} headerTitle="여행 테마" title="어떤 여행을 원하세요?" - subtitle="가장 끌리는 테마 하나를 골라 주세요" + subtitle="가장 끌리는 테마를 최대 3개 골라 주세요" nextHref="/trips/new/places" buttonLabel="장소 선택하기 →" /> diff --git a/src/components/sheet/DateRangeCalendar.tsx b/src/components/sheet/DateRangeCalendar.tsx index a414216..657882a 100644 --- a/src/components/sheet/DateRangeCalendar.tsx +++ b/src/components/sheet/DateRangeCalendar.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"]; @@ -52,21 +53,24 @@ export default function DateRangeCalendar({ } const today = startOfToday(); + const selectedNights = start && end + ? Math.round((end.getTime() - start.getTime()) / 86400000) + : null; return ( -
-
+
+
-

+

{viewedMonth.getFullYear()}년 {viewedMonth.getMonth() + 1}월

-
+
{WEEKDAYS.map((w, i) => ( {w} @@ -94,33 +98,63 @@ export default function DateRangeCalendar({ ))}
-
+
{days.map((date, i) => { - if (!date) return ; - const edge = (start && sameDay(date, start)) || (end && sameDay(date, end)); + if (!date) return ; + const startEdge = Boolean(start && sameDay(date, start)); + const endEdge = Boolean(end && sameDay(date, end)); const within = inRange(date); const isPast = date.getTime() < today.getTime(); + const column = i % 7; + const hasRange = Boolean(start && end && start.getTime() !== end.getTime()); + const weekday = date.getDay(); return ( - + + {within && ( + + )} + {hasRange && startEdge && column < 6 && ( + + )} + {hasRange && endEdge && column > 0 && ( + + )} + + ); })}
+ + {selectedNights != null && ( +

+ {selectedNights === 0 + ? "당일 여행 선택됨" + : `${selectedNights}박 ${selectedNights + 1}일 선택됨`} +

+ )}
); } diff --git a/src/components/sheet/LocationPickerSheet.tsx b/src/components/sheet/LocationPickerSheet.tsx index 354e1dc..6c4d8da 100644 --- a/src/components/sheet/LocationPickerSheet.tsx +++ b/src/components/sheet/LocationPickerSheet.tsx @@ -25,12 +25,22 @@ export default function LocationPickerSheet({ initial ?? null, ); const searchWrapRef = useRef(null); + const closeTimerRef = useRef | null>(null); useEffect(() => { const raf = requestAnimationFrame(() => setShown(true)); - return () => cancelAnimationFrame(raf); + return () => { + cancelAnimationFrame(raf); + if (closeTimerRef.current) clearTimeout(closeTimerRef.current); + }; }, []); + function closeSheet() { + if (closeTimerRef.current) return; + setShown(false); + closeTimerRef.current = setTimeout(onClose, 300); + } + useEffect(() => { function onClickOutside(e: MouseEvent) { if (searchWrapRef.current && !searchWrapRef.current.contains(e.target as Node)) @@ -73,7 +83,7 @@ export default function LocationPickerSheet({ function confirm() { if (!candidate) return; onSelect(candidate); - onClose(); + closeSheet(); } return ( @@ -82,13 +92,16 @@ export default function LocationPickerSheet({ + ); + })} +
+ ); + } + + if (uiStep === 3) { + return ( +
+ {sorted.map((answer) => { + const Icon = answerIcon(answer.label); + const on = selectedIds.includes(answer.id); + const atMax = multiple && !on && selectedIds.length >= max; + return ( + + ); + })} +
+ ); + } + return ( -
+
{sorted.map((a) => { - const Icon = vibeIcon(a.label); + const Icon = answerIcon(a.label); const on = selectedIds.includes(a.id); const atMax = multiple && !on && selectedIds.length >= max; return ( @@ -99,15 +179,15 @@ function AnswerGroup({ type="button" disabled={atMax} onClick={() => onToggle(a.id)} - className={`flex flex-col items-center gap-1.5 rounded-2xl border p-3 text-center text-xs font-semibold transition-all ${ + className={`flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] border p-3 text-center text-sm font-bold transition-all ${ on - ? "border-transparent bg-linear-to-br from-[#2E7DF2] to-[#17B89B] text-white shadow-sm" + ? "border-2 border-[#2f7ff2] bg-[#edf6ff] text-[#2376e9] shadow-[0_8px_18px_rgba(47,127,242,0.08)]" : atMax - ? "cursor-not-allowed border-zinc-100 bg-zinc-50 text-zinc-300" - : "border-zinc-200 bg-white text-zinc-600" + ? "cursor-not-allowed border-[#e6eaf0] bg-[#fafbfc] text-[#c5ccd7]" + : "border-[#d7e0ec] bg-white text-[#14294d] hover:border-[#a9cafa]" }`} > - + {a.label} ); @@ -190,46 +270,80 @@ export default function QuestionStep({ } return ( - + -
-
-

{title}

- {subtitle &&

{subtitle}

} +
+
+
+ +
+
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )}
+ {uiStep === 1 && ( +
+ +
+

+ 누구와 떠나시든, 좋아하는 스타일에 맞춰 +

+

딱 맞는 코스를 추천해드려요.

+
+
+ )} + {error && ( -

+

질문을 불러오지 못했어요. 잠시 후 다시 시도해 주세요.

)} {!error && questions === null && ( -

불러오는 중...

+
+ +
)} +
{stepQuestions.map((q) => { const multiple = q.type === "MULTIPLE_CHOICE"; const selectedIds = selectedIdsFor(q); return (
-
-

{q.text}

+
+
+

+ {q.text} +

+ {multiple && ( +

+ {q.minSelections}~{q.maxSelections}개 선택 가능 +

+ )} +
{multiple && ( -

- {q.minSelections}~{q.maxSelections}개 선택 가능 ·{" "} - 0 ? "font-semibold text-[#2E7DF2]" : "" - } - > - {selectedIds.length}/{q.maxSelections} - -

+ + {selectedIds.length} / {q.maxSelections} 선택 + )}
); })} +
- +
+ +
); diff --git a/src/components/ui/StepProgress.tsx b/src/components/ui/StepProgress.tsx index 8917d55..8dae51d 100644 --- a/src/components/ui/StepProgress.tsx +++ b/src/components/ui/StepProgress.tsx @@ -1,6 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; +import { ChevronLeft } from "lucide-react"; export default function StepProgress({ step, @@ -15,28 +16,28 @@ export default function StepProgress({ const percent = (step / total) * 100; return ( -
+
{title && ( -

+

{title}

)} - - {step}/{total} + + {step} / {total}
-
+
From 16c03fa2e2726f0c8507a7639c5b521ca1122e6d Mon Sep 17 00:00:00 2001 From: bong Date: Sat, 19 Sep 2026 20:18:25 +0900 Subject: [PATCH 2/2] fix: css update --- src/app/(app)/profile/page.tsx | 340 +++++++++++++-------- src/app/(app)/trips/page.tsx | 40 +-- src/components/map/BusanDistrictPicker.tsx | 129 ++++++-- src/components/map/NaverMap.tsx | 7 +- src/components/trip/ScheduleCourseView.tsx | 52 +++- src/components/trip/ScheduleDetail.tsx | 15 +- 6 files changed, 409 insertions(+), 174 deletions(-) diff --git a/src/app/(app)/profile/page.tsx b/src/app/(app)/profile/page.tsx index 559f8a4..263150a 100644 --- a/src/app/(app)/profile/page.tsx +++ b/src/app/(app)/profile/page.tsx @@ -1,180 +1,282 @@ "use client"; -import { useEffect, useState } from "react"; +import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { signOut, useSession } from "next-auth/react"; -import { Ban, Bell, Bookmark, ChevronRight, Heart, HelpCircle, Info, LogOut, MapPin, Pencil, User } from "lucide-react"; -import AppHeader from "@/components/layout/AppHeader"; -import { getMyWishlist, WISHLIST_MAX_PAGE_SIZE } from "@/lib/api/wishlists"; +import { useEffect, useState } from "react"; +import { + Ban, + Bell, + Bookmark, + ChevronLeft, + ChevronRight, + Heart, + HelpCircle, + Info, + LogOut, + Luggage, + MapPin, + Pencil, + User, +} from "lucide-react"; + +import { getSchedules } from "@/lib/api/schedules"; import { getUserProfile, type UserProfile } from "@/lib/api/users"; +import { getMyWishlist, WISHLIST_MAX_PAGE_SIZE } from "@/lib/api/wishlists"; const APP_VERSION = "0.1.0"; +function avatarLabel(name: string) { + const normalized = name.replace(/[0-9\s_-]/g, ""); + if (!normalized) return "누비"; + if (/^[가-힣]+$/.test(normalized) && normalized.length >= 3) { + return normalized.slice(1, 3); + } + return normalized.slice(0, 2).toUpperCase(); +} + +function countLabel(value: number | null, maximum?: number) { + if (value == null) return "-"; + return maximum != null && value >= maximum ? `${maximum}+` : value.toLocaleString(); +} + +function SectionHeading({ title, signature }: { title: string; signature?: boolean }) { + return ( +
+

+ {title} +

+ + {signature && ( + + Busan~ + + )} +
+ ); +} + export default function ProfilePage() { - const { data: session } = useSession(); + const router = useRouter(); + const { data: session, status } = useSession(); const user = session?.user; const userId = user?.id != null ? Number(user.id) : null; const userIdStr = user?.id != null ? String(user.id) : undefined; const [profile, setProfile] = useState(null); const [profileLoading, setProfileLoading] = useState(true); + const [wishlistCount, setWishlistCount] = useState(null); + const [tripCount, setTripCount] = useState(null); useEffect(() => { + if (status === "loading") return; if (!userId || !userIdStr) return; + + let cancelled = false; getUserProfile(userId) - .then(setProfile) + .then((response) => { + if (!cancelled) setProfile(response); + }) .catch(() => {}) - .finally(() => setProfileLoading(false)); - }, [userId, userIdStr]); - - const displayName = profile?.nickname ?? user?.name ?? "게스트"; - const displayImage = profile ? profile.profileImageUrl : null; - - const [wishlistCount, setWishlistCount] = useState(null); + .finally(() => { + if (!cancelled) setProfileLoading(false); + }); + return () => { + cancelled = true; + }; + }, [status, userId, userIdStr]); useEffect(() => { if (!userId) return; - getMyWishlist() - .then((res) => setWishlistCount(res.items.length)) + let cancelled = false; + + void getMyWishlist() + .then((response) => { + if (!cancelled) setWishlistCount(response.items.length); + }) .catch(() => {}); + void getSchedules() + .then((response) => { + if (!cancelled) setTripCount(response.items.length); + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; }, [userId]); + const displayName = profile?.nickname ?? user?.name ?? "게스트"; + const displayImage = profile?.profileImageUrl ?? user?.image ?? null; + return ( -
- - -
-
-
- {!profileLoading && displayImage && ( - +
+ +
+ +

+ 내 정보 +

+
+ +
+
+
+ {displayImage ? ( + + ) : profileLoading ? ( + + ) : ( + avatarLabel(displayName) )}
+
{profileLoading ? ( <> -
-
+
+
) : ( <> -

{displayName}

-

{user?.email ?? ""}

+

+ {displayName} +

+

{user?.email ?? ""}

)}
- - - -
-
- - - - 여행 - - {wishlistCount == null ? "-" : wishlistCount >= WISHLIST_MAX_PAGE_SIZE ? `${WISHLIST_MAX_PAGE_SIZE}+` : wishlistCount} - 찜한 장소 + + 수정 -
+
-
- - - - - 내 여행 - +
+ + + 여행 + {countLabel(tripCount)} + - - - - - 찜한 장소 - + + + 찜한 장소 + + {countLabel(wishlistCount, WISHLIST_MAX_PAGE_SIZE)} + - - - - - 북마크 - - - {user?.id && ( +
+ +
+ +
- - - - 내 프로필 - + + 내 여행 + - )} - - - - - 차단한 사용자 - - -
- -
-
- - - - 알림 설정 - 준비중 -
-
- - - - 문의하기 - 준비중 + + + 찜한 장소 + + + + + 북마크 + + + {user?.id ? ( + + + 내 프로필 + + + ) : ( +
+ + 내 프로필 +
+ )}
-
- - - - 앱 버전 - v{APP_VERSION} +
+ +
+ +
+ + + 차단한 사용자 + + +
+ + 알림 설정 + 준비중 + +
+
+ + 문의하기 + 준비중 + +
+
+ + 앱 버전 + v{APP_VERSION} +
-
+
diff --git a/src/app/(app)/trips/page.tsx b/src/app/(app)/trips/page.tsx index 8b509da..54fd56e 100644 --- a/src/app/(app)/trips/page.tsx +++ b/src/app/(app)/trips/page.tsx @@ -215,11 +215,11 @@ export default function TripsPage() { className="object-cover object-top" />
-

내 일정

+

내 일정

언제나,
부산으로
@@ -228,7 +228,7 @@ export default function TripsPage() { 좋은 곳이
더 많은 부산
@@ -250,7 +250,7 @@ export default function TripsPage() {
{featured && (
-

가장 가까운 여행

+

가장 가까운 여행

바다,
그리고 부산
@@ -271,23 +271,23 @@ export default function TripsPage() {
- + {getFeaturedStatusLabel(featured, now)} - + {formatDateLabel(featured.startDate, featured.endDate)} -

+

{getScheduleTitle(featured)}

-

+

{formatDuration(featured.dayCount)} · {featured.stopCount}곳

- 일정 보기 + 일정 보기
@@ -302,8 +302,8 @@ export default function TripsPage() { > - 일정 계획하기 - 3분이면 완성 + 일정 계획하기 + 3분이면 완성 @@ -314,8 +314,8 @@ export default function TripsPage() { > - 제로플랜 - 지금 바로 출발 + 제로플랜 + 지금 바로 출발 @@ -323,10 +323,10 @@ export default function TripsPage() {
-

+

모든 일정 {sorted.length}

- + 전체 보기
@@ -352,11 +352,11 @@ export default function TripsPage() { /> - {getScheduleTitle(t)} - + {getScheduleTitle(t)} + {formatDateLabel(t.startDate, t.endDate)} · {t.stopCount}곳 - + {getStatus(t, now)} diff --git a/src/components/map/BusanDistrictPicker.tsx b/src/components/map/BusanDistrictPicker.tsx index 8c73af2..5c1c51a 100644 --- a/src/components/map/BusanDistrictPicker.tsx +++ b/src/components/map/BusanDistrictPicker.tsx @@ -24,7 +24,15 @@ function makeGeo(polygons: Polygon[], bbox: BBox): THREE.ExtrudeGeometry { } return shape; }); - return new THREE.ExtrudeGeometry(shapes, { depth: 0.28, bevelEnabled: true, bevelThickness: 0.02, bevelSize: 0.02, bevelSegments: 1 }); + const geometry = new THREE.ExtrudeGeometry(shapes, { + depth: 0.42, + bevelEnabled: true, + bevelThickness: 0.035, + bevelSize: 0.028, + bevelSegments: 2, + }); + geometry.computeVertexNormals(); + return geometry; } function CameraController({ @@ -40,7 +48,7 @@ function CameraController({ const speed = Math.min(1, dt * 4); if (target) { const [cx, cy] = target; - const targetPos = new THREE.Vector3(cx * 0.6, 4.5 * distance, -cy * 0.6 + 3 * distance); + const targetPos = new THREE.Vector3(cx * 0.6, 4.8 * distance, -cy * 0.6 + 4 * distance); const targetLook = new THREE.Vector3(cx * 0.6, 0, -cy * 0.6); camera.position.lerp(targetPos, speed); const currentLook = new THREE.Vector3(); @@ -48,7 +56,7 @@ function CameraController({ const dir = targetLook.clone().sub(camera.position).normalize(); camera.lookAt(camera.position.clone().add(currentLook.lerp(dir, speed))); } else { - const overviewPos = new THREE.Vector3(0, 9 * distance, 3 * distance); + const overviewPos = new THREE.Vector3(0, 8.2 * distance, 4.2 * distance); const overviewLook = new THREE.Vector3(0, 0, 0); camera.position.lerp(overviewPos, speed); const currentLook = new THREE.Vector3(); @@ -76,13 +84,21 @@ function DistrictBlock({ }) { const ref = useRef(null); const [pressing, setPressing] = useState(false); + const [hovering, setHovering] = useState(false); const zRef = useRef(0); const geo = useMemo(() => makeGeo(district.polygons, bbox), [district, bbox]); + const edges = useMemo(() => new THREE.EdgesGeometry(geo, 28), [geo]); + const surfaceColor = useMemo(() => { + const base = new THREE.Color(selected ? "#2E7DF2" : color); + if (hovering && !selected) base.lerp(new THREE.Color("#dff8ff"), 0.2); + return base; + }, [color, hovering, selected]); useFrame((_, dt) => { if (!ref.current) return; - const target = pressing ? -0.24 : 0; - zRef.current += (target - zRef.current) * Math.min(1, dt * 14); + const restingHeight = selected ? 0.48 : hovering ? 0.2 : 0; + const target = pressing ? Math.max(0.06, restingHeight - 0.1) : restingHeight; + zRef.current += (target - zRef.current) * Math.min(1, dt * 12); ref.current.position.z = zRef.current; }); @@ -90,6 +106,13 @@ function DistrictBlock({ { + e.stopPropagation(); + setHovering(true); + }} onPointerDown={(e) => { e.stopPropagation(); setPressing(true); @@ -99,15 +122,26 @@ function DistrictBlock({ setPressing(false); onSelect(); }} - onPointerLeave={() => setPressing(false)} + onPointerLeave={() => { + setHovering(false); + setPressing(false); + }} > + + + ); } @@ -118,38 +152,47 @@ const LABEL_OFFSETS: Record = { function DistrictLabel({ district, + selected, onSelect, }: { district: District; + selected: boolean; onSelect: () => void; }) { const [cx, cy] = district.centroid; const [dx, dy] = LABEL_OFFSETS[district.code] ?? [0, 0]; return ( - {district.name} - + ); } @@ -172,10 +215,27 @@ function Scene({ return ( <> - - - + + + + + + + + {districts.map((d, i) => ( onSelect(d.code)} /> ))} @@ -252,9 +313,31 @@ export default function BusanDistrictPicker({ } return ( -
+
+