diff --git a/package-lock.json b/package-lock.json index 1d16790e..675fe989 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@amplitude/unified": "^1.1.28", + "@ncdai/react-wheel-picker": "^1.2.3", "@next/third-parties": "^16.2.1", "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.95.2", @@ -3211,6 +3212,18 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@ncdai/react-wheel-picker": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@ncdai/react-wheel-picker/-/react-wheel-picker-1.2.3.tgz", + "integrity": "sha512-TQjDfuDH9Ss7sgacSRPnakqWfkR8E1w6g7Pkt7bTYX35SfdHbGSzC8g/lXKFeMyNIikOWD9wC3uFPLy+Ui/R0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ncdai" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/@next/env": { "version": "16.2.1", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz", diff --git a/package.json b/package.json index c327e613..9432c968 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@amplitude/unified": "^1.1.28", + "@ncdai/react-wheel-picker": "^1.2.3", "@next/third-parties": "^16.2.1", "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.95.2", diff --git a/src/app/(main)/bookmark/_components/BookmarkPlacePreviewCard.tsx b/src/app/(main)/bookmark/_components/BookmarkPlacePreviewCard.tsx index 398f129e..8599dd87 100644 --- a/src/app/(main)/bookmark/_components/BookmarkPlacePreviewCard.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkPlacePreviewCard.tsx @@ -6,19 +6,15 @@ import { useInViewport } from "@/hooks/useInViewport"; import { usePlacePhotoUrlQuery } from "@/hooks/usePlacePhotoUrl"; import { MAIN_CARD_INNER_PADDING_X_CLASS } from "@/lib/layout-tokens"; import { handlePlacePhotoImageError } from "@/lib/places/place-photo-refresh"; -import { - buildGoogleMapsPlaceUrl, - normalizeGooglePlaceResourceId, -} from "@/lib/maps"; import { cn } from "@/lib/utils"; export type BookmarkPlacePreviewCardProps = { name: string; address?: string; primaryTypeDisplayName?: string; - /** 있으면 사진 클릭 시 Google Maps로 이동 */ googlePlaceId?: string; onClick?: () => void; + onThumbnailClick?: () => void; className?: string; contentClassName?: string; }; @@ -29,6 +25,7 @@ export function BookmarkPlacePreviewCard({ primaryTypeDisplayName, googlePlaceId, onClick, + onThumbnailClick, className, contentClassName, }: BookmarkPlacePreviewCardProps) { @@ -38,12 +35,6 @@ export function BookmarkPlacePreviewCard({ }); const rawGid = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; - const googleMapsPlaceUrl = rawGid.length - ? buildGoogleMapsPlaceUrl({ - placeId: normalizeGooglePlaceResourceId(rawGid), - query: name, - }) - : null; const thumbnailMedia = imageUrl ? ( // eslint-disable-next-line @next/next/no-img-element -- remote place photo URL @@ -100,15 +91,15 @@ export function BookmarkPlacePreviewCard({ ) : null} - {googleMapsPlaceUrl ? ( - e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - aria-label={`${name} — Google Maps에서 열기`} + type="button" + onClick={(e) => { + e.stopPropagation(); + onThumbnailClick(); + }} + aria-label={`${name} 지도에서 보기`} className="relative block h-[80px] w-[80px] shrink-0 cursor-pointer overflow-hidden rounded-lg bg-light-gray transition hover:brightness-105" draggable={false} > @@ -119,7 +110,7 @@ export function BookmarkPlacePreviewCard({ > - + ) : (
c.categoryId === selectedCategoryId, + )?.colorCode; + + const handleOpenOnMap = useCallback( + (googlePlaceId: string, preview: PlacePreview) => { + if (busy) return; + onClose(); + openPlaceOnMap( + { + name: preview.name, + category: "", + rating: null, + address: preview.formattedAddress, + googlePlaceId: googlePlaceId.trim(), + location: preview.location, + fromBookmark: true, + bookmarkCategoryColor: selectedCategoryColor, + }, + { itinerarySource: "bookmark" }, + ); + }, + [busy, onClose, openPlaceOnMap, selectedCategoryColor], + ); + return (
void handleAddPlace(googlePlaceId)} + onThumbnailClick={() => + handleOpenOnMap(googlePlaceId, preview) + } /> ); }) diff --git a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx index f42a211e..9bfab2ea 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx @@ -21,10 +21,7 @@ import { import { usePlanPlaceCardPhoto } from "@/hooks/usePlanPlaceCardPhoto"; import { bucketItemCount } from "@/lib/analytics/context"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; -import { - buildGoogleMapsPlaceUrl, - normalizeGooglePlaceResourceId, -} from "@/lib/maps"; +import { normalizeGooglePlaceResourceId } from "@/lib/maps"; import { PLAN_PLACE_CARD_TW } from "@/lib/layout-tokens"; import { handlePlacePhotoImageError } from "@/lib/places/place-photo-refresh"; import { @@ -218,12 +215,7 @@ export function PlanPlaceCard({ const rawGooglePlaceId = typeof place.googlePlaceId === "string" ? place.googlePlaceId.trim() : ""; - const googleMapsPlaceUrl = rawGooglePlaceId.length - ? buildGoogleMapsPlaceUrl({ - placeId: normalizeGooglePlaceResourceId(rawGooglePlaceId), - query: place.title, - }) - : null; + const hasGooglePlace = rawGooglePlaceId.length > 0; const memoText = typeof place.memo === "string" ? place.memo.trim() : ""; const hasMemo = memoText.length > 0; @@ -334,7 +326,7 @@ export function PlanPlaceCard({ /> ) : null; - if (!googleMapsPlaceUrl) { + if (!hasGooglePlace) { return (
{media} @@ -343,16 +335,8 @@ export function PlanPlaceCard({ } return ( - e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - aria-label={`${place.title} — Google Maps에서 열기`} className={cn( PLAN_PLACE_CARD_TW.thumbnail, "block cursor-pointer transition hover:brightness-105", @@ -365,7 +349,7 @@ export function PlanPlaceCard({ > - +
); })()} diff --git a/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx b/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx index 2b1d75ca..56ebcb59 100644 --- a/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx +++ b/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx @@ -1,216 +1,107 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; - -import { cn } from "@/lib/utils"; - -const ITEM_HEIGHT = 40; -const VISIBLE_ROWS = 5; -const CENTER_INDEX = Math.floor(VISIBLE_ROWS / 2); -const CONTAINER_HEIGHT = VISIBLE_ROWS * ITEM_HEIGHT; -const CENTER_PADDING = CENTER_INDEX * ITEM_HEIGHT; -const SCROLL_SETTLE_MS = 110; - -type WheelColumnProps = { - items: number[]; - value: number; - onChange: (next: number) => void; - ariaLabel: string; - disabled?: boolean; -}; +import { + WheelPicker, + WheelPickerWrapper, + type WheelPickerOption, +} from "@ncdai/react-wheel-picker"; + +type Period = "AM" | "PM"; + +/** Figma Time Picker Base — 행 44px, 5행(≈220px)이 보이도록 링 개수 16 */ +const OPTION_HEIGHT = 44; +const VISIBLE_COUNT = 16; + +const PERIOD_OPTIONS: WheelPickerOption[] = [ + { value: "AM", label: "오전" }, + { value: "PM", label: "오후" }, +]; +const HOUR_OPTIONS: WheelPickerOption[] = Array.from( + { length: 12 }, + (_, i) => ({ value: i, label: String(i) }), +); +const MINUTE_OPTIONS: WheelPickerOption[] = Array.from( + { length: 60 }, + (_, i) => ({ value: i, label: String(i).padStart(2, "0") }), +); + +function splitHm(hm: string): { period: Period; hour: number; minute: number } { + const [h, m] = hm.split(":").map(Number); + return { period: h < 12 ? "AM" : "PM", hour: h % 12, minute: m }; +} -function nearestIndex(items: number[], value: number): number { - let best = 0; - let bestDelta = Number.POSITIVE_INFINITY; - for (let i = 0; i < items.length; i += 1) { - const d = Math.abs(items[i]! - value); - if (d < bestDelta) { - best = i; - bestDelta = d; - } - } - return best; +function joinHm(period: Period, hour: number, minute: number): string { + const h = hour + (period === "PM" ? 12 : 0); + return `${String(h).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; } -function WheelColumn({ - items, +/** 12시간제 휠 — `value`/`onChange`는 24시간 HH:mm */ +export function TimeWheelPicker({ value, onChange, - ariaLabel, - disabled = false, -}: WheelColumnProps) { - const scrollRef = useRef(null); - const settleTimerRef = useRef(null); - const suppressScrollRef = useRef(false); - - const selectedIndex = nearestIndex(items, value); - - const scrollToIndex = useCallback((index: number, smooth: boolean) => { - const el = scrollRef.current; - if (!el) return; - const target = index * ITEM_HEIGHT; - if (Math.abs(el.scrollTop - target) < 0.5) return; - suppressScrollRef.current = true; - el.scrollTo({ top: target, behavior: smooth ? "smooth" : "auto" }); - window.setTimeout(() => { - suppressScrollRef.current = false; - }, smooth ? 260 : 40); - }, []); - - useEffect(() => { - scrollToIndex(selectedIndex, false); - }, [selectedIndex, scrollToIndex]); - - useEffect(() => { - return () => { - if (settleTimerRef.current !== null) { - window.clearTimeout(settleTimerRef.current); - } - }; - }, []); - - const commit = useCallback( - (index: number) => { - const clamped = Math.max(0, Math.min(items.length - 1, index)); - onChange(items[clamped]!); - }, - [items, onChange], - ); - - const handleScroll = () => { - if (suppressScrollRef.current) return; - const el = scrollRef.current; - if (!el) return; - if (settleTimerRef.current !== null) { - window.clearTimeout(settleTimerRef.current); - } - settleTimerRef.current = window.setTimeout(() => { - const idx = Math.round(el.scrollTop / ITEM_HEIGHT); - commit(idx); - }, SCROLL_SETTLE_MS); + active, + onInteract, +}: Readonly<{ + value: string; + onChange: (hm: string) => void; + /** 편집 중인 필드가 있을 때만 중앙 값을 진하게 표시 */ + active: boolean; + /** 휠을 건드리는 순간 — 편집 대상 필드가 없으면 부모가 지정 */ + onInteract: () => void; +}>) { + const { period, hour, minute } = splitHm(value); + const classNames = { + optionItem: "text-heading-s text-text-disabled", + highlightWrapper: "bg-fill text-heading-s", + highlightItem: active ? "text-text" : "text-text-disabled", }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (disabled) return; - if (e.key === "ArrowUp") { - e.preventDefault(); - commit(selectedIndex - 1); - } else if (e.key === "ArrowDown") { - e.preventDefault(); - commit(selectedIndex + 1); - } else if (e.key === "PageUp") { - e.preventDefault(); - commit(selectedIndex - 5); - } else if (e.key === "PageDown") { - e.preventDefault(); - commit(selectedIndex + 5); - } else if (e.key === "Home") { - e.preventDefault(); - commit(0); - } else if (e.key === "End") { - e.preventDefault(); - commit(items.length - 1); - } + const shared = { + classNames, + optionItemHeight: OPTION_HEIGHT, + visibleCount: VISIBLE_COUNT, }; return (
{ + if (e.key !== "Tab" && e.key !== "Escape") onInteract(); }} > -
- {items.map((v, i) => { - const isSelected = i === selectedIndex; - return ( - - ); - })} -
-
- ); -} - -export type TimeWheelValue = { hour: number; minute: number }; - -type TimeWheelPickerProps = { - value: TimeWheelValue; - onChange: (next: TimeWheelValue) => void; - disabled?: boolean; -}; - -export function TimeWheelPicker({ - value, - onChange, - disabled, -}: TimeWheelPickerProps) { - const hours = useMemo(() => Array.from({ length: 24 }, (_, i) => i), []); - const minutes = useMemo(() => Array.from({ length: 60 }, (_, i) => i), []); - - return ( -
- -
- onChange({ hour: h, minute: value.minute })} - ariaLabel="시" - disabled={disabled} - /> - - : - - onChange({ hour: value.hour, minute: m })} - ariaLabel="분" - disabled={disabled} - /> -
+ +
+ onChange(joinHm(next, hour, minute))} + /> +
+
+ onChange(joinHm(period, next, minute))} + /> +
+
+ onChange(joinHm(period, hour, next))} + /> +
+
); } diff --git a/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx b/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx index 5c9ffb2c..d6bf4add 100644 --- a/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx +++ b/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx @@ -62,16 +62,18 @@ export function MobileMemoSheet({ >