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.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,
+ }),
+}));