diff --git a/public/assets/map-marker-selected.png b/public/assets/map-marker-selected.png new file mode 100644 index 0000000..2773bbd Binary files /dev/null and b/public/assets/map-marker-selected.png differ diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 120e0a2..c9c6aa6 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -5,6 +5,7 @@ import { RootLayout } from "@/app/layouts/RootLayout"; import { OnboardingGate } from "@/app/router/OnboardingGate"; import { ProtectedRoute } from "@/app/router/ProtectedRoute"; import AuthCallbackPage from "@/pages/AuthCallbackPage"; +import DevClickPlacePage from "@/pages/dev/DevClickPlacePage"; import DevSelectOptionPage from "@/pages/dev/DevSelectOptionPage"; import EntryPage from "@/pages/EntryPage"; import LoginPage from "@/pages/LoginPage"; @@ -26,6 +27,7 @@ export const router = createBrowserRouter([ children: [ { index: true, element: }, { path: "dev/splash", element: }, + { path: "dev/click_place", element: }, { path: "dev/SelectOption", element: }, { path: "login", element: }, { path: "app", element: }, diff --git a/src/components/map/KakaoMapView.tsx b/src/components/map/KakaoMapView.tsx index 30543e5..38f783a 100644 --- a/src/components/map/KakaoMapView.tsx +++ b/src/components/map/KakaoMapView.tsx @@ -9,6 +9,7 @@ import { loadKakaoMapSdk, } from "@/shared/lib/kakao-map-sdk"; import type { MapCoordinate, SavedPlace } from "@/shared/types/map-home"; +import { PLACE_DETAIL_OPEN_EVENT, usePlaceDetailStore } from "@/store/placeDetailStore"; export type KakaoMapViewProps = { appKey?: string; @@ -33,8 +34,10 @@ export function KakaoMapView({ appKey, places, center, level = 4, className }: K const mapRef = useRef(null); const mapsRef = useRef(null); const markerImageRef = useRef(null); + const selectedMarkerImageRef = useRef(null); const markerInstancesRef = useRef([]); const [loadState, setLoadState] = useState("loading"); + const selectedPlaceId = usePlaceDetailStore((state) => state.selectedPlaceId); const clearMarkers = () => { markerInstancesRef.current.forEach((marker) => marker.setMap(null)); @@ -74,6 +77,11 @@ export function KakaoMapView({ appKey, places, center, level = 4, className }: K new kakao.maps.Size(30, 40), { offset: new kakao.maps.Point(15, 39) }, ); + selectedMarkerImageRef.current = new kakao.maps.MarkerImage( + "/assets/map-marker-selected.png", + new kakao.maps.Size(42, 56), + { offset: new kakao.maps.Point(21, 55) }, + ); setLoadState("ready"); }) @@ -88,6 +96,7 @@ export function KakaoMapView({ appKey, places, center, level = 4, className }: K mapRef.current = null; mapsRef.current = null; markerImageRef.current = null; + selectedMarkerImageRef.current = null; }; }, [hasMapKey, mapKey]); @@ -102,27 +111,44 @@ export function KakaoMapView({ appKey, places, center, level = 4, className }: K // effect C: places 변경 시 marker만 갱신 useEffect(() => { - if (loadState !== "ready" || !mapRef.current || !mapsRef.current || !markerImageRef.current) { + if ( + loadState !== "ready" || + !mapRef.current || + !mapsRef.current || + !markerImageRef.current || + !selectedMarkerImageRef.current + ) { return; } const maps = mapsRef.current; const mapInstance = mapRef.current; const markerImage = markerImageRef.current; + const selectedMarkerImage = selectedMarkerImageRef.current; clearMarkers(); markerInstancesRef.current = places.map((place) => { - return new maps.Marker({ + const marker = new maps.Marker({ map: mapInstance, title: place.name, position: new maps.LatLng(place.latitude, place.longitude), - image: markerImage, + image: place.id === selectedPlaceId ? selectedMarkerImage : markerImage, }); + + maps.event.addListener(marker, "click", () => { + window.dispatchEvent( + new CustomEvent(PLACE_DETAIL_OPEN_EVENT, { + detail: { placeId: place.id }, + }), + ); + }); + + return marker; }); return () => { clearMarkers(); }; - }, [loadState, places]); + }, [loadState, places, selectedPlaceId]); return (
diff --git a/src/components/place/BusinessHoursAccordion.tsx b/src/components/place/BusinessHoursAccordion.tsx new file mode 100644 index 0000000..1274ff1 --- /dev/null +++ b/src/components/place/BusinessHoursAccordion.tsx @@ -0,0 +1,86 @@ +import { ChevronDown } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "@/lib/utils"; +import type { ResolvedPlaceBusinessHours } from "@/shared/types/map-home"; + +type BusinessHoursAccordionProps = { + businessHours: ResolvedPlaceBusinessHours | null | undefined; +}; + +function buildStatusSummary(businessHours: ResolvedPlaceBusinessHours): string { + if (businessHours.openTime) { + return `${businessHours.status} ${businessHours.openTime} 오픈`; + } + + return businessHours.status; +} + +export function BusinessHoursAccordion({ businessHours }: BusinessHoursAccordionProps) { + const [isExpanded, setIsExpanded] = useState(false); + + const todayHours = useMemo(() => { + return ( + businessHours?.weeklyHours.find((row) => row.isToday) ?? businessHours?.weeklyHours[0] ?? null + ); + }, [businessHours]); + + if (!businessHours) { + return ( +
+

영업시간

+

정보 없음

+
+ ); + } + + return ( +
+
+

영업시간

+

{buildStatusSummary(businessHours)}

+ {businessHours.holidayNotice ? ( +

{businessHours.holidayNotice}

+ ) : null} +
+ + {todayHours ? ( +
+

+ {todayHours.label} {todayHours.hours} +

+ + + + {isExpanded ? ( +
+ {businessHours.weeklyHours.map((row) => ( +
+ {row.label} + {row.hours} +
+ ))} +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/components/place/PlaceDetailSheet.tsx b/src/components/place/PlaceDetailSheet.tsx new file mode 100644 index 0000000..78a7ac7 --- /dev/null +++ b/src/components/place/PlaceDetailSheet.tsx @@ -0,0 +1,64 @@ +import { ExternalLink, MapPin } from "lucide-react"; +import { type JSX, useEffect, useMemo } from "react"; + +import { BusinessHoursAccordion } from "@/components/place/BusinessHoursAccordion"; +import { BottomSheet } from "@/components/ui/BottomSheet"; +import { SAVED_PLACE_MOCKS } from "@/pages/map/map-home-mock"; +import { resolveSavedPlacesBusinessHours, useKoreanNow } from "@/shared/lib/place-business-hours"; +import { usePlaceDetailStore } from "@/store/placeDetailStore"; + +export function PlaceDetailSheet(): JSX.Element | null { + const { isOpen, selectedPlaceId, closeDetail } = usePlaceDetailStore((state) => state); + const now = useKoreanNow(); + const places = useMemo(() => resolveSavedPlacesBusinessHours(SAVED_PLACE_MOCKS, now), [now]); + + const place = places.find((item) => item.id === selectedPlaceId) ?? null; + + useEffect(() => { + if (isOpen && selectedPlaceId && !place) { + closeDetail(); + } + }, [closeDetail, isOpen, place, selectedPlaceId]); + + if (!place) { + return null; + } + + return ( + +
+
+ +
+

{place.name}

+
+ +

{place.address}

+
+
+ + {place.reelsUrl ? ( + + ) : null} + + +
+ + ); +} diff --git a/src/features/map/hooks/use-place-filter-data.ts b/src/features/map/hooks/use-place-filter-data.ts index ca727b3..00cd0bc 100644 --- a/src/features/map/hooks/use-place-filter-data.ts +++ b/src/features/map/hooks/use-place-filter-data.ts @@ -6,7 +6,7 @@ import { type MapPrimaryCategory, } from "@/shared/types/map-home"; -import type { Category } from "../api/place-taxonomy-types"; +import type { Category, PlaceFilterData } from "../api/place-taxonomy-types"; import { usePlaceFilterOptionsQuery } from "./use-place-filter-options-query"; const EMPTY_FILTER_CATEGORIES: Category[] = []; @@ -20,14 +20,17 @@ type UsePlaceFilterDataResult = { retryLoad: () => Promise; }; -export function usePlaceFilterData(): UsePlaceFilterDataResult { +export function usePlaceFilterData( + filterDataOverride?: PlaceFilterData | null, +): UsePlaceFilterDataResult { const { data: placeFilterData, isPending, isError, refetch } = usePlaceFilterOptionsQuery(); + const resolvedFilterData = filterDataOverride ?? placeFilterData; - const hasInitialData = Boolean(placeFilterData); + const hasInitialData = Boolean(resolvedFilterData); const filterCategories = useMemo( - () => placeFilterData?.categories ?? EMPTY_FILTER_CATEGORIES, - [placeFilterData], + () => resolvedFilterData?.categories ?? EMPTY_FILTER_CATEGORIES, + [resolvedFilterData], ); const categories = useMemo( @@ -53,8 +56,8 @@ export function usePlaceFilterData(): UsePlaceFilterDataResult { categories, categoryNameByCode, filterCategories, - isInitialLoading: !hasInitialData && isPending, - isInitialError: !hasInitialData && isError, + isInitialLoading: !filterDataOverride && !hasInitialData && isPending, + isInitialError: !filterDataOverride && !hasInitialData && isError, retryLoad, }; } diff --git a/src/pages/MapHomePage.tsx b/src/pages/MapHomePage.tsx index 12ce64d..6361789 100644 --- a/src/pages/MapHomePage.tsx +++ b/src/pages/MapHomePage.tsx @@ -1,13 +1,21 @@ import { type JSX } from "react"; -import { MapHomePageContent } from "@/pages/map/MapHomePage"; +import type { PlaceFilterData } from "@/features/map/api/place-taxonomy-types"; +import MyHomePage_WithDetail from "@/pages/MyHomePage_WithDetail"; type MapHomePageProps = { defaultFilterPanelOpen?: boolean; + filterDataOverride?: PlaceFilterData | null; }; export default function MapHomePage({ defaultFilterPanelOpen = false, + filterDataOverride = null, }: MapHomePageProps): JSX.Element { - return ; + return ( + + ); } diff --git a/src/pages/MyHomePage_WithDetail.tsx b/src/pages/MyHomePage_WithDetail.tsx new file mode 100644 index 0000000..6c4b207 --- /dev/null +++ b/src/pages/MyHomePage_WithDetail.tsx @@ -0,0 +1,49 @@ +import { type JSX, useEffect } from "react"; + +import { PlaceDetailSheet } from "@/components/place/PlaceDetailSheet"; +import type { PlaceFilterData } from "@/features/map/api/place-taxonomy-types"; +import { MapHomePageContent } from "@/pages/map/MapHomePage"; +import { PLACE_DETAIL_OPEN_EVENT, usePlaceDetailStore } from "@/store/placeDetailStore"; + +type MyHomePageWithDetailProps = { + defaultFilterPanelOpen?: boolean; + filterDataOverride?: PlaceFilterData | null; +}; + +type PlaceDetailOpenEvent = CustomEvent<{ + placeId: string; +}>; + +export default function MyHomePage_WithDetail({ + defaultFilterPanelOpen = false, + filterDataOverride = null, +}: MyHomePageWithDetailProps): JSX.Element { + const openDetail = usePlaceDetailStore((state) => state.openDetail); + + useEffect(() => { + const handleOpenDetail = (event: Event) => { + const { detail } = event as PlaceDetailOpenEvent; + if (!detail?.placeId) { + return; + } + + openDetail(detail.placeId); + }; + + window.addEventListener(PLACE_DETAIL_OPEN_EVENT, handleOpenDetail); + + return () => { + window.removeEventListener(PLACE_DETAIL_OPEN_EVENT, handleOpenDetail); + }; + }, [openDetail]); + + return ( + <> + + + + ); +} diff --git a/src/pages/dev/DevClickPlacePage.tsx b/src/pages/dev/DevClickPlacePage.tsx new file mode 100644 index 0000000..908af38 --- /dev/null +++ b/src/pages/dev/DevClickPlacePage.tsx @@ -0,0 +1,28 @@ +import { useLayoutEffect } from "react"; + +import MyHomePage_WithDetail from "@/pages/MyHomePage_WithDetail"; +import { useRoomSelectionStore } from "@/store/room-selection-store"; + +const DEV_ROOM = { + id: "dev-room-click-place", + name: "친구 님과의 데이트 지도", + memberCount: 3, +}; + +export default function DevClickPlacePage() { + const selectRoom = useRoomSelectionStore((state) => state.selectRoom); + const selectedRoom = useRoomSelectionStore((state) => state.selectedRoom); + const isDevRoomReady = selectedRoom?.id === DEV_ROOM.id; + + useLayoutEffect(() => { + if (!isDevRoomReady) { + selectRoom(DEV_ROOM); + } + }, [isDevRoomReady, selectRoom]); + + if (!isDevRoomReady) { + return null; + } + + return ; +} diff --git a/src/pages/dev/DevSelectOptionPage.tsx b/src/pages/dev/DevSelectOptionPage.tsx index 137fcb3..3a4587f 100644 --- a/src/pages/dev/DevSelectOptionPage.tsx +++ b/src/pages/dev/DevSelectOptionPage.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense, useLayoutEffect } from "react"; +import type { PlaceFilterData } from "@/features/map/api/place-taxonomy-types"; import { useRoomSelectionStore } from "@/store/room-selection-store"; const MapHomePage = lazy(() => import("@/pages/MapHomePage")); @@ -10,6 +11,117 @@ const DEV_ROOM = { memberCount: 4, }; +const DEV_FILTER_DATA: PlaceFilterData = { + categories: [ + { + code: "\uB9DB\uC9D1", + name: "\uB9DB\uC9D1", + sortOrder: 1, + tagGroups: [ + { + code: "\uB9DB\uC9D1-default", + name: null, + sortOrder: 1, + tags: [ + { code: "\uB9DB\uC9D1-\uD55C\uC2DD", name: "\uD55C\uC2DD", sortOrder: 1 }, + { code: "\uB9DB\uC9D1-\uC911\uC2DD", name: "\uC911\uC2DD", sortOrder: 2 }, + { code: "\uB9DB\uC9D1-\uC77C\uC2DD", name: "\uC77C\uC2DD", sortOrder: 3 }, + { code: "\uB9DB\uC9D1-\uC591\uC2DD", name: "\uC591\uC2DD", sortOrder: 4 }, + { code: "\uB9DB\uC9D1-\uBD84\uC2DD", name: "\uBD84\uC2DD", sortOrder: 5 }, + { + code: "\uB9DB\uC9D1-\uC544\uC2DC\uC544\uC2DD", + name: "\uC544\uC2DC\uC544\uC2DD", + sortOrder: 6, + }, + { code: "\uB9DB\uC9D1-\uC220\uC9D1", name: "\uC220\uC9D1", sortOrder: 7 }, + { code: "\uB9DB\uC9D1-\uAE30\uD0C0", name: "\uAE30\uD0C0", sortOrder: 8 }, + ], + }, + ], + }, + { + code: "\uCE74\uD398", + name: "\uCE74\uD398", + sortOrder: 2, + tagGroups: [ + { + code: "\uCE74\uD398-default", + name: null, + sortOrder: 1, + tags: [ + { + code: "\uCE74\uD398-\uC81C\uACFC/\uBCA0\uC774\uCEE4\uB9AC", + name: "\uC81C\uACFC/\uBCA0\uC774\uCEE4\uB9AC", + sortOrder: 1, + }, + ], + }, + ], + }, + { + code: "\uB180\uAC70\uB9AC", + name: "\uB180\uAC70\uB9AC", + sortOrder: 3, + tagGroups: [ + { + code: "\uB180\uAC70\uB9AC-default", + name: null, + sortOrder: 1, + tags: [ + { + code: "\uB180\uAC70\uB9AC-\uD14C\uB9C8\uD30C\uD06C", + name: "\uD14C\uB9C8\uD30C\uD06C", + sortOrder: 1, + }, + { + code: "\uB180\uAC70\uB9AC-\uBCF4\uB4DC\uCE74\uD398", + name: "\uBCF4\uB4DC\uCE74\uD398", + sortOrder: 2, + }, + { + code: "\uB180\uAC70\uB9AC-\uB9CC\uD654\uCE74\uD398", + name: "\uB9CC\uD654\uCE74\uD398", + sortOrder: 3, + }, + { + code: "\uB180\uAC70\uB9AC-\uBB38\uD654/\uC608\uC220", + name: "\uBB38\uD654/\uC608\uC220", + sortOrder: 4, + }, + { + code: "\uB180\uAC70\uB9AC-\uBC29\uD0C8\uCD9C\uCE74\uD398", + name: "\uBC29\uD0C8\uCD9C\uCE74\uD398", + sortOrder: 5, + }, + { + code: "\uB180\uAC70\uB9AC-\uC2A4\uD3EC\uCE20", + name: "\uC2A4\uD3EC\uCE20", + sortOrder: 6, + }, + { + code: "\uB180\uAC70\uB9AC-\uCC1C\uC9C8\uBC29", + name: "\uCC1C\uC9C8\uBC29", + sortOrder: 7, + }, + { code: "\uB180\uAC70\uB9AC-\uACF5\uC6D0", name: "\uACF5\uC6D0", sortOrder: 8 }, + { + code: "\uB180\uAC70\uB9AC-\uC0DD\uD65C\uC6A9\uD488\uC810", + name: "\uC0DD\uD65C\uC6A9\uD488\uC810", + sortOrder: 9, + }, + { + code: "\uB180\uAC70\uB9AC-\uC544\uCFE0\uC544\uB9AC\uC6C0", + name: "\uC544\uCFE0\uC544\uB9AC\uC6C0", + sortOrder: 10, + }, + { code: "\uB180\uAC70\uB9AC-\uAE30\uD0C0", name: "\uAE30\uD0C0", sortOrder: 11 }, + ], + }, + ], + }, + ], +}; + export default function DevSelectOptionPage() { const selectRoom = useRoomSelectionStore((state) => state.selectRoom); const selectedRoom = useRoomSelectionStore((state) => state.selectedRoom); @@ -27,7 +139,7 @@ export default function DevSelectOptionPage() { return ( - + ); } diff --git a/src/pages/map/MapHomePage.tsx b/src/pages/map/MapHomePage.tsx index 03de77e..56ec25b 100644 --- a/src/pages/map/MapHomePage.tsx +++ b/src/pages/map/MapHomePage.tsx @@ -6,6 +6,7 @@ import { BottomNavToast } from "@/components/common/BottomNavToast"; import { FriendFloatingMenu } from "@/components/map/FriendFloatingMenu"; import { MapHeader } from "@/components/map/MapHeader"; import { MapSearchOverlay } from "@/components/map/MapSearchOverlay"; +import type { PlaceFilterData } from "@/features/map/api/place-taxonomy-types"; 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"; @@ -14,6 +15,7 @@ import { MAP_SEARCH_PLACEHOLDER, SAVED_PLACE_MOCKS, } from "@/pages/map/map-home-mock"; +import { resolveSavedPlacesBusinessHours, useKoreanNow } from "@/shared/lib/place-business-hours"; import type { RoomFriend } from "@/shared/types/map-home"; import type { SelectedRoom } from "@/store/room-selection-store"; import { useRoomSelectionStore } from "@/store/room-selection-store"; @@ -34,15 +36,19 @@ const KakaoMapView = lazy(() => type MapHomePageContentProps = { defaultFilterPanelOpen?: boolean; + filterDataOverride?: PlaceFilterData | null; }; export function MapHomePageContent({ defaultFilterPanelOpen = false, + filterDataOverride = null, }: MapHomePageContentProps): JSX.Element { const selectedRoom = useRoomSelectionStore((s) => s.selectedRoom); const { toastMessage, handleSelectBottomNav } = useBottomNavController(); const [friendMenuOpen, setFriendMenuOpen] = useState(false); + const now = useKoreanNow(); const mapTitle = selectedRoom ? selectedRoom.name : "데이트 지도"; + const places = useMemo(() => resolveSavedPlacesBusinessHours(SAVED_PLACE_MOCKS, now), [now]); const { categories, categoryNameByCode, @@ -50,7 +56,7 @@ export function MapHomePageContent({ isInitialLoading, isInitialError, retryLoad, - } = usePlaceFilterData(); + } = usePlaceFilterData(filterDataOverride); const { keyword, @@ -66,7 +72,7 @@ export function MapHomePageContent({ resetFocusedCategoryTags, filteredPlaces, } = useMapSearchFilters({ - places: SAVED_PLACE_MOCKS, + places, filterCategories, initialFocusedCategory: defaultFilterPanelOpen ? (filterCategories[0]?.code ?? null) : null, }); diff --git a/src/pages/map/map-home-mock.ts b/src/pages/map/map-home-mock.ts index 678cbc6..613a20e 100644 --- a/src/pages/map/map-home-mock.ts +++ b/src/pages/map/map-home-mock.ts @@ -1,6 +1,6 @@ import type { MapCoordinate, SavedPlace } from "@/shared/types/map-home"; -export const MAP_HOME_TITLE = "친구1 님과의 데이트 지도"; +export const MAP_HOME_TITLE = "친구 1님과의 데이트 지도"; export const MAP_SEARCH_PLACEHOLDER = "저장해놓은 장소를 검색해보세요"; export const MAP_INITIAL_CENTER: MapCoordinate = { @@ -11,56 +11,123 @@ export const MAP_INITIAL_CENTER: MapCoordinate = { export const SAVED_PLACE_MOCKS: SavedPlace[] = [ { id: "place-1", - name: "릴스 저장 맛집 - 경희대 파스타공방", + name: "경희대 파스타공방", category: "맛집", tagKeys: ["맛집-양식"], latitude: 37.59429, longitude: 127.05973, address: "서울 동대문구 회기로 157", + reelsUrl: "https://www.instagram.com/reel/example-place-1", + businessHours: { + holidayNotice: "공휴일 정상 영업", + weeklySchedule: [ + { dayOfWeek: 1, openTime: "10:40", closeTime: "19:30" }, + { dayOfWeek: 2, openTime: "10:40", closeTime: "19:30" }, + { dayOfWeek: 3, openTime: "10:40", closeTime: "19:30" }, + { dayOfWeek: 4, openTime: "10:40", closeTime: "19:30" }, + { dayOfWeek: 5, openTime: "10:40", closeTime: "20:30" }, + { dayOfWeek: 6, openTime: "11:00", closeTime: "20:00" }, + { dayOfWeek: 0, openTime: "11:00", closeTime: "18:00" }, + ], + }, }, { id: "place-2", - name: "릴스 저장 카페 - 휘경 브루잉랩", + name: "휘경 부루잉랩", category: "카페", tagKeys: ["카페-제과-베이커리"], latitude: 37.5924, longitude: 127.06106, address: "서울 동대문구 망우로 32", + reelsUrl: null, + businessHours: { + holidayNotice: "라스트 오더 20:30", + weeklySchedule: [ + { dayOfWeek: 1, openTime: "11:00", closeTime: "21:00" }, + { dayOfWeek: 2, openTime: "11:00", closeTime: "21:00" }, + { dayOfWeek: 3, openTime: "11:00", closeTime: "21:00" }, + { dayOfWeek: 4, openTime: "11:00", closeTime: "21:00" }, + { dayOfWeek: 5, openTime: "11:00", closeTime: "22:00" }, + { dayOfWeek: 6, openTime: "12:00", closeTime: "22:00" }, + { dayOfWeek: 0, openTime: "12:00", closeTime: "20:00" }, + ], + }, }, { id: "place-3", - name: "릴스 저장 놀거리 - 회기 보드게임 라운지", + name: "회기 보드게임 라운지", category: "놀거리", tagKeys: ["놀거리-보드카페"], latitude: 37.59161, longitude: 127.06044, address: "서울 동대문구 이문로 96", + reelsUrl: "https://www.instagram.com/reel/example-place-3", + businessHours: { + holidayNotice: "입장 마감 18:30", + weeklySchedule: [ + { dayOfWeek: 1, openTime: "10:00", closeTime: "19:00" }, + { dayOfWeek: 2, openTime: "10:00", closeTime: "19:00" }, + { dayOfWeek: 3, openTime: "10:00", closeTime: "19:00" }, + { dayOfWeek: 4, openTime: "10:00", closeTime: "19:00" }, + { dayOfWeek: 5, openTime: "10:00", closeTime: "20:00" }, + { dayOfWeek: 6, openTime: "11:00", closeTime: "20:00" }, + { dayOfWeek: 0, openTime: null, closeTime: null }, + ], + }, }, { id: "place-4", - name: "릴스 저장 기타 - 산책 포토스팟", + name: "산책 포토스팟", category: "기타", tagKeys: [], latitude: 37.59094, longitude: 127.06281, address: "서울 동대문구 휘경로 12", + reelsUrl: null, + businessHours: null, }, { id: "place-5", - name: "릴스 저장 맛집 - 이문동 스테이크 키친", + name: "이문동 스테이크 키친", category: "맛집", tagKeys: ["맛집-한식"], latitude: 37.59511, longitude: 127.06307, address: "서울 동대문구 이문로 121", + reelsUrl: null, + businessHours: { + holidayNotice: "브레이크 타임 15:00 ~ 17:00", + weeklySchedule: [ + { dayOfWeek: 1, openTime: "11:30", closeTime: "22:00" }, + { dayOfWeek: 2, openTime: "11:30", closeTime: "22:00" }, + { dayOfWeek: 3, openTime: "11:30", closeTime: "22:00" }, + { dayOfWeek: 4, openTime: "11:30", closeTime: "22:00" }, + { dayOfWeek: 5, openTime: "11:30", closeTime: "23:00" }, + { dayOfWeek: 6, openTime: "12:00", closeTime: "23:00" }, + { dayOfWeek: 0, openTime: "12:00", closeTime: "21:00" }, + ], + }, }, { id: "place-6", - name: "릴스 저장 카페 - 우디 시나몬", + name: "우디 시나몬", category: "카페", tagKeys: ["카페-제과-베이커리"], latitude: 37.59457, longitude: 127.05786, address: "서울 동대문구 한천로 44", + reelsUrl: "https://www.instagram.com/reel/example-place-6", + businessHours: { + holidayNotice: "매주 화요일 휴무", + weeklySchedule: [ + { dayOfWeek: 1, openTime: "09:00", closeTime: "20:00" }, + { dayOfWeek: 2, openTime: null, closeTime: null }, + { dayOfWeek: 3, openTime: "09:00", closeTime: "20:00" }, + { dayOfWeek: 4, openTime: "09:00", closeTime: "20:00" }, + { dayOfWeek: 5, openTime: "09:00", closeTime: "21:00" }, + { dayOfWeek: 6, openTime: "10:00", closeTime: "21:00" }, + { dayOfWeek: 0, openTime: "10:00", closeTime: "19:00" }, + ], + }, }, ]; diff --git a/src/shared/lib/kakao-map-sdk.ts b/src/shared/lib/kakao-map-sdk.ts index c7572f8..0ca7891 100644 --- a/src/shared/lib/kakao-map-sdk.ts +++ b/src/shared/lib/kakao-map-sdk.ts @@ -42,6 +42,10 @@ export type KakaoMarker = { setMap: (map: KakaoMapInstance | null) => void; }; +type KakaoEvent = { + addListener: (target: KakaoMarker, eventName: string, handler: () => void) => void; +}; + export type KakaoMaps = { load: (callback: () => void) => void; LatLng: new (latitude: number, longitude: number) => KakaoLatLng; @@ -54,6 +58,7 @@ export type KakaoMaps = { options?: KakaoMarkerImageOptions, ) => KakaoMarkerImage; Marker: new (options: KakaoMarkerOptions) => KakaoMarker; + event: KakaoEvent; }; export type KakaoNamespace = { diff --git a/src/shared/lib/place-business-hours.ts b/src/shared/lib/place-business-hours.ts new file mode 100644 index 0000000..85b5b85 --- /dev/null +++ b/src/shared/lib/place-business-hours.ts @@ -0,0 +1,201 @@ +import { useEffect, useState } from "react"; + +import type { + PlaceBusinessHourRow, + PlaceBusinessHours, + ResolvedPlaceBusinessHours, + ResolvedSavedPlace, + SavedPlace, +} from "@/shared/types/map-home"; + +const KOREA_TIME_ZONE = "Asia/Seoul"; +const DAY_LABELS = ["일", "월", "화", "수", "목", "금", "토"] as const; +const DEFAULT_CLOSING_SOON_MINUTES = 60; + +type KoreaNowParts = { + year: number; + month: number; + day: number; + dayOfWeek: 0 | 1 | 2 | 3 | 4 | 5 | 6; + minutesOfDay: number; +}; + +function getKoreanNowParts(now: Date): KoreaNowParts { + const dateParts = new Intl.DateTimeFormat("en-CA", { + timeZone: KOREA_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(now); + + const timeParts = new Intl.DateTimeFormat("en-GB", { + timeZone: KOREA_TIME_ZONE, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).formatToParts(now); + + const weekdayText = new Intl.DateTimeFormat("en-US", { + timeZone: KOREA_TIME_ZONE, + weekday: "short", + }).format(now); + + const year = Number(dateParts.find((part) => part.type === "year")?.value ?? "0"); + const month = Number(dateParts.find((part) => part.type === "month")?.value ?? "0"); + const day = Number(dateParts.find((part) => part.type === "day")?.value ?? "0"); + const hour = Number(timeParts.find((part) => part.type === "hour")?.value ?? "0"); + const minute = Number(timeParts.find((part) => part.type === "minute")?.value ?? "0"); + + return { + year, + month, + day, + dayOfWeek: weekdayToIndex(weekdayText), + minutesOfDay: hour * 60 + minute, + }; +} + +function weekdayToIndex(weekday: string): 0 | 1 | 2 | 3 | 4 | 5 | 6 { + switch (weekday) { + case "Sun": + return 0; + case "Mon": + return 1; + case "Tue": + return 2; + case "Wed": + return 3; + case "Thu": + return 4; + case "Fri": + return 5; + case "Sat": + return 6; + default: + return 0; + } +} + +function parseTimeToMinutes(value: string): number { + const [hours, minutes] = value.split(":").map(Number); + return hours * 60 + minutes; +} + +function buildTodayLabel(parts: KoreaNowParts): string { + return `${DAY_LABELS[parts.dayOfWeek]}(${parts.month}/${parts.day})`; +} + +function buildWeeklyHours( + businessHours: PlaceBusinessHours, + parts: KoreaNowParts, +): PlaceBusinessHourRow[] { + return businessHours.weeklySchedule.map((row) => { + const isToday = row.dayOfWeek === parts.dayOfWeek; + const label = isToday ? buildTodayLabel(parts) : DAY_LABELS[row.dayOfWeek]; + const hours = row.openTime && row.closeTime ? `${row.openTime} ~ ${row.closeTime}` : "휴무"; + + return { + label, + hours, + isToday, + }; + }); +} + +function isPlaceBusinessHoursSource( + businessHours: PlaceBusinessHours | ResolvedPlaceBusinessHours | null | undefined, +): businessHours is PlaceBusinessHours { + return Array.isArray((businessHours as PlaceBusinessHours | undefined)?.weeklySchedule); +} + +export function resolvePlaceBusinessHours( + businessHours: PlaceBusinessHours | ResolvedPlaceBusinessHours | null | undefined, + now: Date, +): ResolvedPlaceBusinessHours | null { + if (!businessHours) { + return null; + } + + if (!isPlaceBusinessHoursSource(businessHours)) { + return businessHours; + } + + const parts = getKoreanNowParts(now); + const todaySchedule = businessHours.weeklySchedule.find( + (row) => row.dayOfWeek === parts.dayOfWeek, + ); + const weeklyHours = buildWeeklyHours(businessHours, parts); + + if (!todaySchedule || !todaySchedule.openTime || !todaySchedule.closeTime) { + return { + status: "휴무", + openTime: null, + holidayNotice: businessHours.holidayNotice, + weeklyHours, + }; + } + + const openingMinutes = parseTimeToMinutes(todaySchedule.openTime); + const closingMinutes = parseTimeToMinutes(todaySchedule.closeTime); + const closingSoonMinutes = businessHours.closingSoonMinutes ?? DEFAULT_CLOSING_SOON_MINUTES; + + if (parts.minutesOfDay < openingMinutes) { + return { + status: "영업 전", + openTime: todaySchedule.openTime, + holidayNotice: businessHours.holidayNotice, + weeklyHours, + }; + } + + if (parts.minutesOfDay >= closingMinutes) { + return { + status: "영업 종료", + openTime: null, + holidayNotice: businessHours.holidayNotice, + weeklyHours, + }; + } + + if (closingMinutes - parts.minutesOfDay <= closingSoonMinutes) { + return { + status: "곧 마감", + openTime: null, + holidayNotice: businessHours.holidayNotice, + weeklyHours, + }; + } + + return { + status: "영업 중", + openTime: null, + holidayNotice: businessHours.holidayNotice, + weeklyHours, + }; +} + +export function resolveSavedPlacesBusinessHours( + places: SavedPlace[], + now: Date, +): ResolvedSavedPlace[] { + return places.map((place) => ({ + ...place, + businessHours: resolvePlaceBusinessHours(place.businessHours, now), + })); +} + +export function useKoreanNow(tickMs = 60_000): Date { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const timer = window.setInterval(() => { + setNow(new Date()); + }, tickMs); + + return () => { + window.clearInterval(timer); + }; + }, [tickMs]); + + return now; +} diff --git a/src/shared/types/map-home.ts b/src/shared/types/map-home.ts index 1e28518..1190a66 100644 --- a/src/shared/types/map-home.ts +++ b/src/shared/types/map-home.ts @@ -15,6 +15,37 @@ export type SavedPlace = { latitude: number; longitude: number; address: string; + reelsUrl?: string | null; + businessHours?: PlaceBusinessHours | ResolvedPlaceBusinessHours | null; +}; + +export type ResolvedSavedPlace = Omit & { + businessHours?: ResolvedPlaceBusinessHours | null; +}; + +export type PlaceBusinessHourRow = { + label: string; + hours: string; + isToday?: boolean; +}; + +export type PlaceBusinessHours = { + holidayNotice?: string | null; + weeklySchedule: PlaceBusinessScheduleRow[]; + closingSoonMinutes?: number; +}; + +export type ResolvedPlaceBusinessHours = { + status: string; + openTime?: string | null; + holidayNotice?: string | null; + weeklyHours: PlaceBusinessHourRow[]; +}; + +export type PlaceBusinessScheduleRow = { + dayOfWeek: 0 | 1 | 2 | 3 | 4 | 5 | 6; + openTime: string | null; + closeTime: string | null; }; export type RoomFriend = { diff --git a/src/store/placeDetailStore.ts b/src/store/placeDetailStore.ts new file mode 100644 index 0000000..e7ec73b --- /dev/null +++ b/src/store/placeDetailStore.ts @@ -0,0 +1,25 @@ +import { create } from "zustand"; + +export const PLACE_DETAIL_OPEN_EVENT = "place-detail:open"; + +type PlaceDetailState = { + selectedPlaceId: string | null; + isOpen: boolean; + openDetail: (placeId: string) => void; + closeDetail: () => void; +}; + +export const usePlaceDetailStore = create((set) => ({ + selectedPlaceId: null, + isOpen: false, + openDetail: (placeId) => + set({ + selectedPlaceId: placeId, + isOpen: true, + }), + closeDetail: () => + set({ + selectedPlaceId: null, + isOpen: false, + }), +}));