From a5e064c586353f921123268d06b96b1ad2e5c52c Mon Sep 17 00:00:00 2001 From: winchoose Date: Mon, 16 Mar 2026 01:42:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=EB=A7=B5=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/location-picker/types.ts | 6 + .../location-picker/ui/location-picker.tsx | 287 ++++++++++++++++++ src/page/create-page.tsx | 31 +- src/page/main-page.tsx | 110 ++++++- src/shared/api/domain/feeds/query.ts | 20 +- src/shared/api/query-key.ts | 6 +- src/shared/lib/kakao-map/load-kakao-map.ts | 56 ++++ src/vite-env.d.ts | 14 + .../modal/contents/modal-location-search.tsx | 30 +- .../main/bottom-sheet/bottom-sheet.tsx | 16 +- .../contents/bottom-sheet-location-search.tsx | 24 +- 11 files changed, 538 insertions(+), 62 deletions(-) create mode 100644 src/features/location-picker/types.ts create mode 100644 src/features/location-picker/ui/location-picker.tsx create mode 100644 src/shared/lib/kakao-map/load-kakao-map.ts diff --git a/src/features/location-picker/types.ts b/src/features/location-picker/types.ts new file mode 100644 index 0000000..7368f61 --- /dev/null +++ b/src/features/location-picker/types.ts @@ -0,0 +1,6 @@ +export interface LocationSelection { + name: string; + address: string; + latitude: number; + longitude: number; +} diff --git a/src/features/location-picker/ui/location-picker.tsx b/src/features/location-picker/ui/location-picker.tsx new file mode 100644 index 0000000..f68fc00 --- /dev/null +++ b/src/features/location-picker/ui/location-picker.tsx @@ -0,0 +1,287 @@ +import { useEffect, useRef, useState } from 'react'; +import Input, { type InputSize } from '@shared/ui/input'; +import { FloatingActionButton } from '@shared/ui/floatingActionButton'; +import LocationIcon from '@shared/assets/icon/material-symbols_my-location-outline-rounded.svg?react'; +import { loadKakaoMap } from '@shared/lib/kakao-map/load-kakao-map'; +import type { LocationSelection } from '@features/location-picker/types'; + +const DEFAULT_CENTER = { + latitude: 37.5665, + longitude: 126.978, +}; + +interface LocationPickerProps { + value: LocationSelection | null; + onChange: (value: LocationSelection) => void; + inputSize?: InputSize; + inputPlaceholder?: string; + containerClassName?: string; + searchRowClassName?: string; + mapClassName?: string; +} + +export function LocationPicker({ + value, + onChange, + inputSize = 'sm', + inputPlaceholder = '동을 입력해주세요. 예) 역삼동', + containerClassName = 'flex flex-col gap-[1.6rem] px-[2.4rem] pb-[2.4rem]', + searchRowClassName = 'flex items-center gap-[1.2rem]', + mapClassName = 'relative h-[24rem] w-full overflow-hidden rounded-[16px] border border-gray-200', +}: LocationPickerProps) { + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const placesRef = useRef(null); + const geocoderRef = useRef(null); + const syncTokenRef = useRef(0); + const idleTimeoutRef = useRef(null); + const ignoreNextIdleSyncRef = useRef(false); + const suppressSearchRef = useRef(false); + const [keyword, setKeyword] = useState(value?.name || value?.address || ''); + const [isMapReady, setIsMapReady] = useState(false); + + const syncCenterToSelection = ( + latitude: number, + longitude: number, + fallbackName?: string, + ) => { + if (!geocoderRef.current || !window.kakao?.maps?.services) { + onChange({ + name: fallbackName || '선택한 위치', + address: fallbackName || '선택한 위치', + latitude, + longitude, + }); + return; + } + + syncTokenRef.current += 1; + const currentToken = syncTokenRef.current; + + geocoderRef.current.coord2Address( + longitude, + latitude, + (result: any[], status: string) => { + if (currentToken !== syncTokenRef.current) return; + + const address = + status === window.kakao.maps.services.Status.OK + ? result[0]?.road_address?.address_name || + result[0]?.address?.address_name || + fallbackName || + '선택한 위치' + : fallbackName || '선택한 위치'; + + onChange({ + name: fallbackName || address, + address, + latitude, + longitude, + }); + }, + ); + }; + + const moveMapCenter = ( + latitude: number, + longitude: number, + fallbackName?: string, + ) => { + if (!window.kakao?.maps || !mapRef.current) return; + + ignoreNextIdleSyncRef.current = true; + const position = new window.kakao.maps.LatLng(latitude, longitude); + mapRef.current.panTo(position); + syncCenterToSelection(latitude, longitude, fallbackName); + }; + + useEffect(() => { + let isMounted = true; + + loadKakaoMap() + .then((kakao) => { + if (!isMounted || !mapContainerRef.current) return; + + const center = new kakao.maps.LatLng( + value?.latitude ?? DEFAULT_CENTER.latitude, + value?.longitude ?? DEFAULT_CENTER.longitude, + ); + + const map = new kakao.maps.Map(mapContainerRef.current, { + center, + level: 3, + }); + + kakao.maps.event.addListener(map, 'idle', () => { + if (ignoreNextIdleSyncRef.current) { + ignoreNextIdleSyncRef.current = false; + return; + } + + if (idleTimeoutRef.current) { + window.clearTimeout(idleTimeoutRef.current); + } + + const nextCenter = map.getCenter(); + + idleTimeoutRef.current = window.setTimeout(() => { + syncCenterToSelection(nextCenter.getLat(), nextCenter.getLng()); + }, 180); + }); + + mapRef.current = map; + placesRef.current = new kakao.maps.services.Places(); + geocoderRef.current = new kakao.maps.services.Geocoder(); + setIsMapReady(true); + }) + .catch((error) => { + console.error(error); + }); + + return () => { + isMounted = false; + + if (idleTimeoutRef.current) { + window.clearTimeout(idleTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + if (!isMapReady || !value || !mapRef.current || !window.kakao?.maps) return; + + const center = mapRef.current.getCenter(); + const lat = center.getLat(); + const lng = center.getLng(); + + if (Math.abs(lat - value.latitude) < 0.000001 && Math.abs(lng - value.longitude) < 0.000001) { + return; + } + + ignoreNextIdleSyncRef.current = true; + const position = new window.kakao.maps.LatLng( + value.latitude, + value.longitude, + ); + mapRef.current.panTo(position); + }, [isMapReady, value]); + + useEffect(() => { + if (!isMapReady || !placesRef.current || !window.kakao?.maps?.services) return; + + const trimmedKeyword = keyword.trim(); + + if (trimmedKeyword.length < 2) { + return; + } + + const timeoutId = window.setTimeout(() => { + if (suppressSearchRef.current) { + suppressSearchRef.current = false; + return; + } + + geocoderRef.current?.addressSearch( + trimmedKeyword, + (addressData: any[], addressStatus: string) => { + if ( + addressStatus === window.kakao.maps.services.Status.OK && + addressData[0] + ) { + const firstAddress = addressData[0]; + moveMapCenter( + Number(firstAddress.y), + Number(firstAddress.x), + firstAddress.address_name, + ); + return; + } + + placesRef.current.keywordSearch( + trimmedKeyword, + (placeData: any[], placeStatus: string) => { + if ( + placeStatus !== window.kakao.maps.services.Status.OK || + !placeData[0] + ) { + return; + } + + const firstPlace = placeData[0]; + moveMapCenter( + Number(firstPlace.y), + Number(firstPlace.x), + firstPlace.place_name, + ); + }, + ); + }, + ); + }, 400); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [isMapReady, keyword]); + + const handleCurrentLocation = () => { + if (!navigator.geolocation) { + window.alert('현재 위치를 지원하지 않는 환경입니다.'); + return; + } + + navigator.geolocation.getCurrentPosition( + (position) => { + moveMapCenter( + position.coords.latitude, + position.coords.longitude, + '현재 위치', + ); + }, + (error) => { + console.error(error); + + if (error.code === error.PERMISSION_DENIED) { + window.alert('위치 권한이 꺼져 있어요. 브라우저 위치 권한을 허용해주세요.'); + return; + } + + window.alert('현재 위치를 가져오지 못했습니다. 위치 권한과 네트워크를 확인해주세요.'); + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0, + }, + ); + }; + + return ( +
+
+ setKeyword(event.target.value)} + placeholder={inputPlaceholder} + /> + } + /> +
+ +
+
+
+ +
+
+
+ ); +} diff --git a/src/page/create-page.tsx b/src/page/create-page.tsx index 0ac6b8f..8a65b6a 100644 --- a/src/page/create-page.tsx +++ b/src/page/create-page.tsx @@ -17,6 +17,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { FEED_MUTATION_OPTIONS } from '@shared/api/domain/feeds/query'; // 네가 만든 위치 import { FEED_QUERY_KEY } from '@shared/api/query-key'; import { getPresignedUpload } from '@shared/api/domain/controller/query'; +import type { LocationSelection } from '@features/location-picker/types'; type CreateModalType = 'location' | 'datetime' | null; const CreatPage = () => { @@ -27,8 +28,10 @@ const CreatPage = () => { const [time, setTime] = useState(''); const [openModal, setOpenModal] = useState(null); const navigate = useNavigate(); - const [location, setLocation] = useState(''); // 최종 확정 값 - const [tempLocation, setTempLocation] = useState(''); // 모달용 임시 값 + const [location, setLocation] = useState(null); + const [tempLocation, setTempLocation] = useState( + null, + ); const [dateTime, setDateTime] = useState<{ dateText: string; hour: string; @@ -120,13 +123,12 @@ const CreatPage = () => { /> - - 클릭하여 장소 선택 - - ) + + + 클릭하여 장소 선택 + } onClick={() => { setTempLocation(location); @@ -136,11 +138,12 @@ const CreatPage = () => { setOpenModal('datetime')} />
@@ -174,15 +177,15 @@ const CreatPage = () => { mutate({ title, description: text, - playGround: location, + playGround: location.name || location.address, playDate: isoDate, playCount: parseInt(count.replace(/\D/g, ''), 10), round: parseInt(round.replace(/\D/g, ''), 10), timer: parseInt(time.replace(/\D/g, ''), 10), image: imageUrl, - address: location, - latitude: 37.5665, - longitude: 126.978, + address: location.address, + latitude: location.latitude, + longitude: location.longitude, }); }} > diff --git a/src/page/main-page.tsx b/src/page/main-page.tsx index bedc8fa..c87e2ff 100644 --- a/src/page/main-page.tsx +++ b/src/page/main-page.tsx @@ -14,6 +14,9 @@ import PlusIcon from '@shared/assets/icon/plus.svg?react'; import { useQuery } from '@tanstack/react-query'; import { FEED_QUERY_OPTIONS } from '@shared/api/domain/feeds/query'; import { formatDate } from '@shared/utils/date'; +import type { LocationSelection } from '@features/location-picker/types'; +import { loadKakaoMap } from '@shared/lib/kakao-map/load-kakao-map'; + export type SortType = 'latest' | 'near'; type SheetType = 'location' | 'sort' | null; @@ -32,12 +35,91 @@ const mockNotifications = [ const MainPage = () => { const navigate = useNavigate(); - const [location, setLocation] = useState(''); + const [location, setLocation] = useState(null); + const [currentLocation, setCurrentLocation] = + useState(null); const [sortType, setSortType] = useState('latest'); const [openSheet, setOpenSheet] = useState(null); const [isNotificationOpen, setIsNotificationOpen] = useState(false); - const { data } = useQuery(FEED_QUERY_OPTIONS.LIST()); + + const loadCurrentLocation = () => + new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject(new Error('현재 위치를 지원하지 않는 환경입니다.')); + return; + } + + navigator.geolocation.getCurrentPosition( + async (position) => { + try { + const kakao = await loadKakaoMap(); + const geocoder = new kakao.maps.services.Geocoder(); + + geocoder.coord2Address( + position.coords.longitude, + position.coords.latitude, + (result: any[], status: string) => { + const address = + status === kakao.maps.services.Status.OK + ? result[0]?.road_address?.address_name || + result[0]?.address?.address_name || + '현재 위치' + : '현재 위치'; + + resolve({ + name: address, + address, + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }); + }, + ); + } catch { + resolve({ + name: '현재 위치', + address: '현재 위치', + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }); + } + }, + () => { + reject( + new Error( + '현재 위치를 가져오지 못했습니다. 브라우저 위치 권한을 확인해주세요.', + ), + ); + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0, + }, + ); + }); + + const feedListQuery = FEED_QUERY_OPTIONS.LIST( + sortType === 'near' + ? currentLocation + ? { + sort: 'DISTANCE', + latitude: currentLocation.latitude, + longitude: currentLocation.longitude, + } + : undefined + : location + ? { + sort: 'LATEST', + latitude: location.latitude, + longitude: location.longitude, + } + : { + sort: 'LATEST', + }, + ); + const { data } = useQuery(feedListQuery); if (!data) return null; + return (
{
setOpenSheet('location')} /> { { - setSortType(next); + onChange={async (next) => { + if (next === 'near') { + try { + const nextCurrentLocation = + currentLocation ?? (await loadCurrentLocation()); + setCurrentLocation(nextCurrentLocation); + setLocation(nextCurrentLocation); + setSortType('near'); + } catch (error) { + window.alert( + error instanceof Error + ? error.message + : '현재 위치를 가져오지 못했습니다.', + ); + return; + } + } else { + setSortType('latest'); + } + setOpenSheet(null); }} /> diff --git a/src/shared/api/domain/feeds/query.ts b/src/shared/api/domain/feeds/query.ts index 5c0d3d1..648e800 100644 --- a/src/shared/api/domain/feeds/query.ts +++ b/src/shared/api/domain/feeds/query.ts @@ -8,8 +8,18 @@ import type { GetFeedsResponse, } from '@shared/types/feeds/type'; -const getFeeds = async (): Promise => { - return api.get(END_POINT.FEED.LIST).json(); +interface GetFeedsParams extends Record { + sort?: 'LATEST' | 'DISTANCE'; + latitude?: number; + longitude?: number; +} + +const getFeeds = async (params?: GetFeedsParams): Promise => { + return api + .get(END_POINT.FEED.LIST, { + searchParams: params, + }) + .json(); }; const getFeedDetail = async ( @@ -23,10 +33,10 @@ const postFeed = async (body: CreateFeedRequest) => { }; export const FEED_QUERY_OPTIONS = { - LIST: () => + LIST: (params?: GetFeedsParams) => queryOptions({ - queryKey: FEED_QUERY_KEY.LIST(), - queryFn: getFeeds, + queryKey: FEED_QUERY_KEY.LIST(params), + queryFn: () => getFeeds(params), }), DETAIL: (feedId: number) => queryOptions({ diff --git a/src/shared/api/query-key.ts b/src/shared/api/query-key.ts index 9be8820..6895b11 100644 --- a/src/shared/api/query-key.ts +++ b/src/shared/api/query-key.ts @@ -1,4 +1,8 @@ export const FEED_QUERY_KEY = { - LIST: () => ['feeds'] as const, + LIST: (params?: { + sort?: 'LATEST' | 'DISTANCE'; + latitude?: number; + longitude?: number; + }) => ['feeds', params ?? {}] as const, DETAIL: (feedId: number) => ['feed', feedId] as const, }; diff --git a/src/shared/lib/kakao-map/load-kakao-map.ts b/src/shared/lib/kakao-map/load-kakao-map.ts new file mode 100644 index 0000000..091b695 --- /dev/null +++ b/src/shared/lib/kakao-map/load-kakao-map.ts @@ -0,0 +1,56 @@ +let kakaoMapLoaderPromise: Promise | null = null; + +export function loadKakaoMap() { + if (typeof window === 'undefined') { + return Promise.reject(new Error('Window is not available.')); + } + + if (window.kakao?.maps) { + return new Promise((resolve) => { + window.kakao.maps.load(() => resolve(window.kakao)); + }); + } + + if (kakaoMapLoaderPromise) { + return kakaoMapLoaderPromise; + } + + const appKey = import.meta.env.VITE_KAKAO_MAP_JS_KEY; + + if (!appKey) { + return Promise.reject( + new Error('VITE_KAKAO_MAP_JS_KEY is not configured.'), + ); + } + + kakaoMapLoaderPromise = new Promise((resolve, reject) => { + const existingScript = document.querySelector( + 'script[data-kakao-map-sdk="true"]', + ); + + if (existingScript) { + existingScript.addEventListener('load', () => { + window.kakao.maps.load(() => resolve(window.kakao)); + }); + existingScript.addEventListener('error', () => { + reject(new Error('Failed to load Kakao Map SDK.')); + }); + return; + } + + const script = document.createElement('script'); + script.src = `https://dapi.kakao.com/v2/maps/sdk.js?autoload=false&libraries=services&appkey=${appKey}`; + script.async = true; + script.dataset.kakaoMapSdk = 'true'; + script.onload = () => { + window.kakao.maps.load(() => resolve(window.kakao)); + }; + script.onerror = () => { + reject(new Error('Failed to load Kakao Map SDK.')); + }; + + document.head.appendChild(script); + }); + + return kakaoMapLoaderPromise; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 5be55a6..e28cfdd 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,19 @@ /// +interface ImportMetaEnv { + readonly VITE_BASE_URL: string; + readonly VITE_KAKAO_LOGIN_URL: string; + readonly VITE_KAKAO_MAP_JS_KEY: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + +interface Window { + kakao?: any; +} + declare module '*.svg?react' { import * as React from 'react'; const ReactComponent: React.FC>; diff --git a/src/widgets/create/modal/contents/modal-location-search.tsx b/src/widgets/create/modal/contents/modal-location-search.tsx index 9f16c86..fb663bb 100644 --- a/src/widgets/create/modal/contents/modal-location-search.tsx +++ b/src/widgets/create/modal/contents/modal-location-search.tsx @@ -1,10 +1,9 @@ -import { FloatingActionButton } from '@shared/ui/floatingActionButton'; -import Input from '@shared/ui/input'; -import LocationIcon from '@shared/assets/icon/material-symbols_my-location-outline-rounded.svg?react'; +import { LocationPicker } from '@features/location-picker/ui/location-picker'; +import type { LocationSelection } from '@features/location-picker/types'; interface ModalLocationSearchProps { - value: string; - onChange: (value: string) => void; + value: LocationSelection | null; + onChange: (value: LocationSelection) => void; } export function ModalLocationSearch({ @@ -12,17 +11,14 @@ export function ModalLocationSearch({ onChange, }: ModalLocationSearchProps) { return ( -
- onChange(e.target.value)} - placeholder="예) 역삼동" - /> - } - /> -
+ ); } diff --git a/src/widgets/main/bottom-sheet/bottom-sheet.tsx b/src/widgets/main/bottom-sheet/bottom-sheet.tsx index 644368f..c59a874 100644 --- a/src/widgets/main/bottom-sheet/bottom-sheet.tsx +++ b/src/widgets/main/bottom-sheet/bottom-sheet.tsx @@ -2,7 +2,7 @@ import { BottomSheetContext, useBottomSheetContext, } from '@shared/hooks/use-bottom-sheet-context'; -import type { ReactNode } from 'react'; +import { useEffect, type ReactNode } from 'react'; import { cva } from 'class-variance-authority'; import { cn } from '@shared/utils/cn'; @@ -18,11 +18,25 @@ function Overlay() { return
; } +function BodyScrollLock() { + useEffect(() => { + const originalStyle = window.getComputedStyle(document.body).overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = originalStyle; + }; + }, []); + + return null; +} + function Root({ isOpen, onClose, children }: BottomSheetProps) { if (!isOpen) return null; return ( +
{children}
); diff --git a/src/widgets/main/bottom-sheet/contents/bottom-sheet-location-search.tsx b/src/widgets/main/bottom-sheet/contents/bottom-sheet-location-search.tsx index e089769..399865a 100644 --- a/src/widgets/main/bottom-sheet/contents/bottom-sheet-location-search.tsx +++ b/src/widgets/main/bottom-sheet/contents/bottom-sheet-location-search.tsx @@ -1,28 +1,14 @@ -import { FloatingActionButton } from '@shared/ui/floatingActionButton'; -import Input from '@shared/ui/input'; -import LocationIcon from '@shared/assets/icon/material-symbols_my-location-outline-rounded.svg?react'; +import { LocationPicker } from '@features/location-picker/ui/location-picker'; +import type { LocationSelection } from '@features/location-picker/types'; interface BottomSheetLocationSearchProps { - value: string; - onChange: (value: string) => void; + value: LocationSelection | null; + onChange: (value: LocationSelection) => void; } export function BottomSheetLocationSearch({ value, onChange, }: BottomSheetLocationSearchProps) { - return ( -
- onChange(e.target.value)} - placeholder="동을 입력해주세요. 예) 역삼동" - /> - } - /> -
- ); + return ; }