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
23 changes: 19 additions & 4 deletions src/components/common/SearchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,21 @@ export const SEARCH_FIELD_DEFAULT_PLACEHOLDER = "검색";

export type SearchFieldProps = Omit<React.ComponentProps<"input">, "type"> & {
inputClassName?: string;
searchButtonLabel?: string;
onSubmitSearch?: () => void;
};

export const SearchField = React.forwardRef<HTMLInputElement, SearchFieldProps>(
function SearchField(
{
className,
inputClassName,
searchButtonLabel = "검색",
onSubmitSearch,
id,
placeholder = SEARCH_FIELD_DEFAULT_PLACEHOLDER,
"aria-label": ariaLabel,
onKeyDown,
...inputProps
},
ref,
Expand All @@ -36,12 +41,22 @@ export const SearchField = React.forwardRef<HTMLInputElement, SearchFieldProps>(
"border-input placeholder:text-muted-foreground bg-background text-foreground h-10 w-full rounded-full border px-3.5 pe-10 text-sm outline-none focus-visible:ring-0",
inputClassName,
)}
onKeyDown={(event) => {
onKeyDown?.(event);
if (!event.defaultPrevented && event.key === "Enter") {
onSubmitSearch?.();
}
}}
{...inputProps}
/>
<Search
className="text-muted-foreground pointer-events-none absolute end-2.5 top-1/2 size-4 -translate-y-1/2"
aria-hidden
/>
<button
type="button"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute end-1.5 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-full outline-none focus-visible:ring-2"
aria-label={searchButtonLabel}
onClick={onSubmitSearch}
>
<Search className="size-4" aria-hidden />
</button>
</div>
);
},
Expand Down
5 changes: 4 additions & 1 deletion src/components/course-planner/RegionSelectionPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { X } from "lucide-react";

import { SearchField } from "@/components/common/SearchField";
import { COURSE_DISTRICTS_BY_CITY, COURSE_REGION_CITIES } from "@/features/course-planner/constants";
import {
COURSE_DISTRICTS_BY_CITY,
COURSE_REGION_CITIES,
} from "@/features/course-planner/constants";
import { cn } from "@/lib/utils";

type RegionSelectionPanelProps = {
Expand Down
135 changes: 130 additions & 5 deletions src/components/map/KakaoMapView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ export type KakaoMapViewProps = {
appKey?: string;
places: SavedPlace[];
center: MapCoordinate;
fitBoundsPlaces?: SavedPlace[];
geocodeKeyword?: string;
viewportKey?: string;
level?: number;
className?: string;
onMapClick?: () => void;
};

type MapLoadState = "loading" | "ready" | "error";
Expand All @@ -25,7 +29,17 @@ type MapLoadState = "loading" | "ready" | "error";
* Kakao JS SDK 로딩과 지도/마커 렌더링을 담당.
* 현재는 단일 마커 스타일만 사용하고, 상세 오버레이는 연결하지 않는다.
*/
export function KakaoMapView({ appKey, places, center, level = 4, className }: KakaoMapViewProps) {
export function KakaoMapView({
appKey,
places,
center,
fitBoundsPlaces = [],
geocodeKeyword = "",
viewportKey = "initial",
level = 4,
className,
onMapClick,
}: KakaoMapViewProps) {
const mapKey = appKey?.trim() ?? "";
const hasMapKey = mapKey.length > 0;
const initialCenterRef = useRef(center);
Expand Down Expand Up @@ -100,14 +114,125 @@ export function KakaoMapView({ appKey, places, center, level = 4, className }: K
};
}, [hasMapKey, mapKey]);

// effect B: center 변경 시 setCenter만 수행
// effect B: requested viewport changes.
useEffect(() => {
if (loadState !== "ready" || !mapRef.current || !mapsRef.current) return;
const maps = mapsRef.current;
const mapInstance = mapRef.current;
const nextCenter = new maps.LatLng(center.latitude, center.longitude);
mapInstance.setCenter(nextCenter);
}, [center.latitude, center.longitude, loadState]);

if (fitBoundsPlaces.length > 1) {
const bounds = new maps.LatLngBounds();
fitBoundsPlaces.forEach((place) => {
bounds.extend(new maps.LatLng(place.latitude, place.longitude));
});
mapInstance.setBounds(bounds);
return;
}

if (fitBoundsPlaces.length === 1) {
const [place] = fitBoundsPlaces;
mapInstance.setLevel(level);
mapInstance.setCenter(new maps.LatLng(place.latitude, place.longitude));
return;
}

const trimmedGeocodeKeyword = geocodeKeyword.trim();
if (trimmedGeocodeKeyword && maps.services) {
mapInstance.setLevel(level);
let disposed = false;
const normalizeResultText = (value: string | undefined) =>
(value ?? "").trim().toLowerCase().replace(/\s+/g, "");
const normalizedKeyword = normalizeResultText(trimmedGeocodeKeyword);
const scoreResult = (result: {
place_name?: string;
address_name?: string;
road_address_name?: string;
}) => {
const texts = [
normalizeResultText(result.place_name),
normalizeResultText(result.address_name),
normalizeResultText(result.road_address_name),
];

if (texts.some((text) => text === normalizedKeyword)) return 0;
if (texts.some((text) => text.includes(normalizedKeyword))) return 1;
if (texts.some((text) => normalizedKeyword.includes(text) && text.length > 0)) return 2;
return 3;
};
const moveToBestResult = (
results: Array<{
x: string;
y: string;
place_name?: string;
address_name?: string;
road_address_name?: string;
}>,
) => {
const [result] = [...results].sort((a, b) => scoreResult(a) - scoreResult(b));
const latitude = Number(result?.y);
const longitude = Number(result?.x);
if (disposed || Number.isNaN(latitude) || Number.isNaN(longitude)) {
return false;
}

mapInstance.setCenter(new maps.LatLng(latitude, longitude));
return true;
};
const moveToFallbackCenter = () => {
if (!disposed) {
mapInstance.setCenter(new maps.LatLng(center.latitude, center.longitude));
}
};

const services = maps.services;
const geocoder = new services.Geocoder();
geocoder.addressSearch(trimmedGeocodeKeyword, (addressResults, addressStatus) => {
if (disposed) {
return;
}

if (addressStatus === services.Status.OK && moveToBestResult(addressResults)) {
return;
}

const places = new services.Places();
places.keywordSearch(trimmedGeocodeKeyword, (keywordResults, keywordStatus) => {
if (keywordStatus === services.Status.OK && moveToBestResult(keywordResults)) {
return;
}

moveToFallbackCenter();
});
});

return () => {
disposed = true;
};
}

mapInstance.setLevel(level);
mapInstance.setCenter(new maps.LatLng(center.latitude, center.longitude));
}, [
center.latitude,
center.longitude,
fitBoundsPlaces,
geocodeKeyword,
level,
loadState,
viewportKey,
]);

useEffect(() => {
if (loadState !== "ready" || !mapRef.current || !mapsRef.current || !onMapClick) return;

const maps = mapsRef.current;
const mapInstance = mapRef.current;
maps.event.addListener(mapInstance, "click", onMapClick);

return () => {
maps.event.removeListener(mapInstance, "click", onMapClick);
};
}, [loadState, onMapClick]);

// effect C: places 변경 시 marker만 갱신
useEffect(() => {
Expand Down
18 changes: 17 additions & 1 deletion src/components/map/MapSearchOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
import { memo, useRef } from "react";

import type { MapSearchSuggestion } from "@/features/map/utils/map-search";
import { usePointerDownOutside } from "@/hooks/use-pointer-down-outside";

import { SearchField } from "../common/SearchField";
import { MAP_SEARCH_INPUT_GLASS_CLASS } from "./chip-style";
import { FilterBar } from "./FilterBar";
import type { MapFilterBarProps } from "./filters/map-filter-bar-props";
import { MapSearchSuggestions } from "./MapSearchSuggestions";

export type MapSearchOverlayProps = MapFilterBarProps & {
placeholder: string;
keyword: string;
searchSuggestions: MapSearchSuggestion[];
isSearchSuggestionsOpen: boolean;
onKeywordChange: (keyword: string) => void;
onSubmitSearch: () => void;
onSelectSearchPlace: (placeId: string) => void;
};

export const MapSearchOverlay = memo(function MapSearchOverlay({
placeholder,
keyword,
searchSuggestions,
isSearchSuggestionsOpen,
onKeywordChange,
onSubmitSearch,
onSelectSearchPlace,
onCloseTagPanel,
isTagPanelOpen,
...filterBarProps
Expand All @@ -33,11 +43,17 @@ export const MapSearchOverlay = memo(function MapSearchOverlay({
value={keyword}
placeholder={placeholder}
onChange={(event) => onKeywordChange(event.target.value)}
onSubmitSearch={onSubmitSearch}
inputClassName={MAP_SEARCH_INPUT_GLASS_CLASS}
/>
<MapSearchSuggestions
open={isSearchSuggestionsOpen}
suggestions={searchSuggestions}
onSelectPlace={onSelectSearchPlace}
/>
</div>

<div className="pointer-events-auto">
<div className={isSearchSuggestionsOpen ? "hidden" : "pointer-events-auto"}>
<FilterBar
{...filterBarProps}
onCloseTagPanel={onCloseTagPanel}
Expand Down
63 changes: 63 additions & 0 deletions src/components/map/MapSearchSuggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { MapPin } from "lucide-react";

import type { MapSearchSuggestion } from "@/features/map/utils/map-search";
import { cn } from "@/lib/utils";

import { MAP_SEARCH_SUGGESTIONS_PANEL_CLASS } from "./chip-style";

type MapSearchSuggestionsProps = {
suggestions: MapSearchSuggestion[];
open: boolean;
className?: string;
onSelectPlace: (placeId: string) => void;
};

export function MapSearchSuggestions({
suggestions,
open,
className,
onSelectPlace,
}: MapSearchSuggestionsProps) {
if (!open) {
return null;
}

return (
<div
className={cn(
MAP_SEARCH_SUGGESTIONS_PANEL_CLASS,
"mt-2 overflow-hidden rounded-3xl shadow-sm",
className,
)}
>
{suggestions.length > 0 ? (
<ul
role="list"
aria-label="검색된 저장 장소"
className="flex list-none flex-col gap-1 p-1.5"
>
{suggestions.map(({ place }) => (
<li key={place.id} className="min-w-0">
<button
type="button"
className={cn(
"hover:bg-muted/55 active:bg-muted/65 focus-visible:ring-ring flex w-full flex-col gap-1 rounded-2xl px-4 py-2.5 text-left transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-offset-0",
)}
onClick={() => onSelectPlace(place.id)}
>
<span className="text-foreground line-clamp-1 text-sm font-bold">{place.name}</span>
<span className="text-muted-foreground flex min-w-0 items-start gap-1.5 text-xs font-medium">
<MapPin className="mt-0.5 size-4 shrink-0 text-neutral-400" aria-hidden />
<span className="line-clamp-1">{place.address}</span>
</span>
</button>
</li>
))}
</ul>
) : (
<div className="px-5 py-4 text-sm font-medium text-neutral-500">관련 장소가 없음</div>
)}
</div>
);
}
9 changes: 7 additions & 2 deletions src/components/map/chip-style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,14 @@ export const MAP_CHIP_BADGE_SELECTED_MOBILE_CLASS =
export const MAP_CHIP_BADGE_UNSELECTED_MOBILE_CLASS =
"max-md:absolute max-md:-top-1 max-md:-right-0.5 max-md:z-1 max-md:flex max-md:h-4 max-md:min-w-4 max-md:items-center max-md:justify-center max-md:rounded-full max-md:px-1 max-md:tabular-nums max-md:font-semibold max-md:shadow-sm max-md:border max-md:border-primary/30 max-md:bg-primary max-md:text-primary-foreground";

/** 검색창·검색 제안 패널 공통 글래스 (입력창은 아래 클래스에서 border-width는 기본 input과 함께 사용) */
export const MAP_SEARCH_GLASS_SURFACE_CLASS = "border-border/55 bg-background/85 backdrop-blur-md";

/** 검색창 한 줄만 — 넓은 글래스 패널 없이 얇게 */
export const MAP_SEARCH_INPUT_GLASS_CLASS =
"border-border/55 bg-background/85 backdrop-blur-md placeholder:text-muted-foreground/90";
export const MAP_SEARCH_INPUT_GLASS_CLASS = `${MAP_SEARCH_GLASS_SURFACE_CLASS} placeholder:text-muted-foreground/90`;

/** 검색 제안 드롭다운 패널 (div 래퍼용 — `border` 명시) */
export const MAP_SEARCH_SUGGESTIONS_PANEL_CLASS = `border ${MAP_SEARCH_GLASS_SURFACE_CLASS}`;

export const MAP_FILTER_PANEL_BASE_CLASS =
"mt-2 overflow-hidden rounded-2xl border border-border/35 bg-background/88 shadow-filter-panel backdrop-blur-md transition-all duration-200 ease-out";
Expand Down
4 changes: 1 addition & 3 deletions src/components/mypage/MySavedPlacesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ import { usePointerDownOutside } from "@/hooks/use-pointer-down-outside";
import { cn } from "@/lib/utils";
import { resolveSavedPlacesBusinessHours, useKoreanNow } from "@/shared/lib/place-business-hours";
import { SAVED_PLACE_BY_ID } from "@/shared/mocks/place-mocks";
import {
type SavedPlace as MapSavedPlace,
} from "@/shared/types/map-home";
import { type SavedPlace as MapSavedPlace } from "@/shared/types/map-home";
import type { SavedPlace } from "@/shared/types/my-page";
import { usePlaceDetailStore } from "@/store/place-detail-store";

Expand Down
6 changes: 3 additions & 3 deletions src/features/map/hooks/use-map-search-filters.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState } from "react";

import type { Category } from "@/features/map/api/place-taxonomy-types";
import { includesMapSearchText, normalizeMapSearchText } from "@/features/map/utils/map-search";
import {
MAP_ALL_CATEGORY_FILTER_CHIP,
type MapCategoryFilterChip,
Expand Down Expand Up @@ -282,7 +283,7 @@ export function useMapSearchFilters({
const selectedCategorySet = useMemo(() => new Set(activeCategories), [activeCategories]);

const filteredPlaces = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
const normalizedKeyword = normalizeMapSearchText(keyword);
const hasSelectedCategoryFilter = activeCategories.length > 0;

return places.filter((place) => {
Expand Down Expand Up @@ -322,8 +323,7 @@ export function useMapSearchFilters({
return true;
}

const searchableText = `${place.name} ${place.address}`.toLowerCase();
return searchableText.includes(normalizedKeyword);
return includesMapSearchText(`${place.name} ${place.address}`, keyword);
});
}, [
activeCategories.length,
Expand Down
Loading
Loading