Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/assets/map-marker-selected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/app/router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,6 +27,7 @@ export const router = createBrowserRouter([
children: [
{ index: true, element: <EntryPage /> },
{ path: "dev/splash", element: <SplashScreenPage /> },
{ path: "dev/click_place", element: <DevClickPlacePage /> },
{ path: "dev/SelectOption", element: <DevSelectOptionPage /> },
{ path: "login", element: <LoginPage /> },
{ path: "app", element: <Navigate to="/" replace /> },
Expand Down
34 changes: 30 additions & 4 deletions src/components/map/KakaoMapView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,8 +34,10 @@ export function KakaoMapView({ appKey, places, center, level = 4, className }: K
const mapRef = useRef<KakaoMapInstance | null>(null);
const mapsRef = useRef<KakaoMaps | null>(null);
const markerImageRef = useRef<KakaoMarkerImage | null>(null);
const selectedMarkerImageRef = useRef<KakaoMarkerImage | null>(null);
const markerInstancesRef = useRef<KakaoMarker[]>([]);
const [loadState, setLoadState] = useState<MapLoadState>("loading");
const selectedPlaceId = usePlaceDetailStore((state) => state.selectedPlaceId);

const clearMarkers = () => {
markerInstancesRef.current.forEach((marker) => marker.setMap(null));
Expand Down Expand Up @@ -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");
})
Expand All @@ -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]);

Expand All @@ -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 (
<div className={cn("bg-map-placeholder-bg relative h-full w-full", className)}>
Expand Down
86 changes: 86 additions & 0 deletions src/components/place/BusinessHoursAccordion.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="space-y-1">
<p className="text-sm font-semibold text-slate-900">영업시간</p>
<p className="text-sm text-slate-500">정보 없음</p>
</section>
);
}

return (
<section className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900">영업시간</p>
<p className="text-sm font-semibold text-slate-900">{buildStatusSummary(businessHours)}</p>
{businessHours.holidayNotice ? (
<p className="text-sm text-slate-500">{businessHours.holidayNotice}</p>
) : null}
</div>

{todayHours ? (
<div className="border-t border-slate-100 pt-3">
<p className="text-sm font-semibold text-slate-900">
{todayHours.label} {todayHours.hours}
</p>

<button
type="button"
className="mt-3 flex w-full items-center justify-between gap-3 text-sm font-medium text-slate-700"
aria-expanded={isExpanded}
onClick={() => setIsExpanded((current) => !current)}
>
<span>전체 영업시간</span>
<ChevronDown
className={cn("size-4 transition-transform", isExpanded ? "rotate-180" : "")}
aria-hidden
/>
</button>

{isExpanded ? (
<div className="mt-3 space-y-2">
{businessHours.weeklyHours.map((row) => (
<div
key={`${row.label}-${row.hours}`}
className={cn(
"flex items-center justify-between gap-4 text-sm",
row.isToday ? "font-semibold text-slate-900" : "text-slate-500",
)}
>
<span>{row.label}</span>
<span>{row.hours}</span>
</div>
))}
</div>
) : null}
</div>
) : null}
</section>
);
}
64 changes: 64 additions & 0 deletions src/components/place/PlaceDetailSheet.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<BottomSheet
open={isOpen}
onClose={closeDetail}
hideHandle
className="z-40"
overlayClassName="bg-black/10"
panelClassName="rounded-t-3xl shadow-xl"
>
<div className="space-y-4 px-5 py-4">
<div className="mx-auto h-1.5 w-12 rounded-full bg-gray-300" aria-hidden />

<div className="space-y-2">
<h2 className="text-2xl font-bold tracking-tight text-slate-950">{place.name}</h2>
<div className="flex items-start gap-2 text-sm text-slate-500">
<MapPin className="mt-0.5 size-4 shrink-0" />
<p>{place.address}</p>
</div>
</div>

{place.reelsUrl ? (
<button
type="button"
className="flex w-full items-center justify-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-3 text-sm font-medium text-slate-800"
onClick={() => {
window.open(place.reelsUrl ?? "", "_blank", "noopener,noreferrer");
}}
>
<span>내가 봤던 릴스 다시보기</span>
<ExternalLink className="size-4" />
</button>
) : null}

<BusinessHoursAccordion businessHours={place.businessHours} />
</div>
</BottomSheet>
);
}
17 changes: 10 additions & 7 deletions src/features/map/hooks/use-place-filter-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -20,14 +20,17 @@ type UsePlaceFilterDataResult = {
retryLoad: () => Promise<unknown>;
};

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(
Expand All @@ -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,
};
}
12 changes: 10 additions & 2 deletions src/pages/MapHomePage.tsx
Original file line number Diff line number Diff line change
@@ -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 <MapHomePageContent defaultFilterPanelOpen={defaultFilterPanelOpen} />;
return (
<MyHomePage_WithDetail
defaultFilterPanelOpen={defaultFilterPanelOpen}
filterDataOverride={filterDataOverride}
/>
);
}
49 changes: 49 additions & 0 deletions src/pages/MyHomePage_WithDetail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<MapHomePageContent
defaultFilterPanelOpen={defaultFilterPanelOpen}
filterDataOverride={filterDataOverride}
/>
<PlaceDetailSheet />
</>
);
}
Loading
Loading