From fe9f72943a147c62356ba716c2aa37d361d529ec Mon Sep 17 00:00:00 2001 From: Yeonjin Kim Date: Mon, 27 Apr 2026 15:33:09 +0900 Subject: [PATCH 1/8] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=C2=B7?= =?UTF-8?q?=ED=95=A0=20=EC=9D=BC=20=EA=B3=B5=ED=86=B5=20UI=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=9C=84=EC=B9=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/addSchedule/useScheduleFooter.tsx | 2 +- src/shared/hooks/addTodo/useTodoFooter.tsx | 2 +- src/shared/types/schedule/types.ts | 46 +++ .../ScheduleDateTimeSection.tsx | 4 +- .../ScheduleEditor/ScheduleDetailsSection.tsx | 2 +- .../ScheduleEditor/ScheduleRepeatSection.tsx | 6 +- .../ScheduleEditor/ScheduleTitleField.tsx | 2 +- .../Modals/TodoEditor/TodoDateTimeSection.tsx | 4 +- .../Modals/TodoEditor/TodoRepeatSection.tsx | 6 +- .../ui/Modals/TodoEditor/TodoTitleField.tsx | 2 +- .../CustomBasisPanel/CustomBasisPanel.tsx | 2 +- .../CustomDatePicker.style.ts | 0 .../CustomDatePicker/CustomDatePicker.tsx | 0 .../CustomTimePicker.style.ts | 0 .../CustomTimePicker/CustomTimePicker.tsx | 0 .../common/SearchPlace/SearchPlace.types.ts | 23 -- .../ui/common/SearchPlace/useSearchPlace.ts | 346 ------------------ .../RecentSearch/RecentSearch.style.ts | 0 .../RecentSearch/RecentSearch.tsx | 0 .../RepeatPanel/DailyRepeatPanel.tsx | 0 .../RepeatPanel/MonthlyRepeatPanel.tsx | 0 .../RepeatPanel/RepeatPanel.style.ts | 0 .../RepeatPanel/WeeklyRepeatPanel.tsx | 0 .../RepeatPanel/YearlyRepeatPanel.tsx | 0 .../RepeatPanel/index.ts | 0 .../RepeatTypeGroup/RepeatTypeGroup.style.ts | 0 .../RepeatTypeGroup/RepeatTypeGroup.tsx | 0 .../SearchPlace/SearchPlace.style.ts | 0 .../SearchPlace/SearchPlace.tsx | 6 +- .../SearchPlace/SearchPlacePanel.tsx | 21 +- .../SelectColor/SelectColor.style.ts | 0 .../SelectColor/SelectColor.tsx | 0 .../TerminationPanel.style.ts | 0 .../TerminationPanel/TerminationPanel.tsx | 2 +- .../TitleSuggestionInput.style.ts | 0 .../TitleSuggestionInput.tsx | 0 .../searchPlace.ts} | 2 +- src/shared/utils/usePlaceSearch.ts | 216 +++++++++++ src/shared/utils/useRecentPlaceSearches.ts | 39 ++ src/shared/utils/useSearchPlace.ts | 228 ++++++++++++ src/shared/utils/useSearchPlacePanelModel.ts | 132 +++++++ 41 files changed, 685 insertions(+), 408 deletions(-) create mode 100644 src/shared/types/schedule/types.ts rename src/shared/ui/{common => calendar}/CustomBasisPanel/CustomBasisPanel.tsx (97%) rename src/shared/ui/{common => calendar}/CustomDatePicker/CustomDatePicker.style.ts (100%) rename src/shared/ui/{common => calendar}/CustomDatePicker/CustomDatePicker.tsx (100%) rename src/shared/ui/{common => calendar}/CustomTimePicker/CustomTimePicker.style.ts (100%) rename src/shared/ui/{common => calendar}/CustomTimePicker/CustomTimePicker.tsx (100%) delete mode 100644 src/shared/ui/common/SearchPlace/SearchPlace.types.ts delete mode 100644 src/shared/ui/common/SearchPlace/useSearchPlace.ts rename src/shared/ui/{common => scheduleTodo}/RecentSearch/RecentSearch.style.ts (100%) rename src/shared/ui/{common => scheduleTodo}/RecentSearch/RecentSearch.tsx (100%) rename src/shared/ui/{common => scheduleTodo}/RepeatPanel/DailyRepeatPanel.tsx (100%) rename src/shared/ui/{common => scheduleTodo}/RepeatPanel/MonthlyRepeatPanel.tsx (100%) rename src/shared/ui/{common => scheduleTodo}/RepeatPanel/RepeatPanel.style.ts (100%) rename src/shared/ui/{common => scheduleTodo}/RepeatPanel/WeeklyRepeatPanel.tsx (100%) rename src/shared/ui/{common => scheduleTodo}/RepeatPanel/YearlyRepeatPanel.tsx (100%) rename src/shared/ui/{common => scheduleTodo}/RepeatPanel/index.ts (100%) rename src/shared/ui/{common => scheduleTodo}/RepeatTypeGroup/RepeatTypeGroup.style.ts (100%) rename src/shared/ui/{common => scheduleTodo}/RepeatTypeGroup/RepeatTypeGroup.tsx (100%) rename src/shared/ui/{common => scheduleTodo}/SearchPlace/SearchPlace.style.ts (100%) rename src/shared/ui/{common => scheduleTodo}/SearchPlace/SearchPlace.tsx (96%) rename src/shared/ui/{common => scheduleTodo}/SearchPlace/SearchPlacePanel.tsx (77%) rename src/shared/ui/{common => scheduleTodo}/SelectColor/SelectColor.style.ts (100%) rename src/shared/ui/{common => scheduleTodo}/SelectColor/SelectColor.tsx (100%) rename src/shared/ui/{common => scheduleTodo}/TerminationPanel/TerminationPanel.style.ts (100%) rename src/shared/ui/{common => scheduleTodo}/TerminationPanel/TerminationPanel.tsx (98%) rename src/shared/ui/{common => scheduleTodo}/TitleSuggestionInput/TitleSuggestionInput.style.ts (100%) rename src/shared/ui/{common => scheduleTodo}/TitleSuggestionInput/TitleSuggestionInput.tsx (100%) rename src/shared/{ui/common/SearchPlace/SearchPlace.utils.ts => utils/searchPlace.ts} (95%) create mode 100644 src/shared/utils/usePlaceSearch.ts create mode 100644 src/shared/utils/useRecentPlaceSearches.ts create mode 100644 src/shared/utils/useSearchPlace.ts create mode 100644 src/shared/utils/useSearchPlacePanelModel.ts diff --git a/src/shared/hooks/addSchedule/useScheduleFooter.tsx b/src/shared/hooks/addSchedule/useScheduleFooter.tsx index 83913c6..ba48d71 100644 --- a/src/shared/hooks/addSchedule/useScheduleFooter.tsx +++ b/src/shared/hooks/addSchedule/useScheduleFooter.tsx @@ -8,7 +8,7 @@ import type { CalendarEvent } from '@/shared/types/calendar/types' import type { EventColorType, ScheduleEditorFormValues } from '@/shared/types/event/event' import type { RecurrenceEventScope } from '@/shared/types/recurrence/recurrence' import type { RepeatConfig } from '@/shared/types/recurrence/repeat' -import SelectColor from '@/shared/ui/common/SelectColor/SelectColor' +import SelectColor from '@/shared/ui/scheduleTodo/SelectColor/SelectColor' type UseScheduleFooterProps = { repeatConfig: RepeatConfig diff --git a/src/shared/hooks/addTodo/useTodoFooter.tsx b/src/shared/hooks/addTodo/useTodoFooter.tsx index fad326a..dab52ff 100644 --- a/src/shared/hooks/addTodo/useTodoFooter.tsx +++ b/src/shared/hooks/addTodo/useTodoFooter.tsx @@ -6,7 +6,7 @@ import type { CalendarEvent } from '@/shared/types/calendar/types' import type { EventColorType } from '@/shared/types/event/event' import type { TodoEditorFormProps } from '@/shared/types/modal/todoEditor' import type { RepeatConfig } from '@/shared/types/recurrence/repeat' -import SelectColor from '@/shared/ui/common/SelectColor/SelectColor' +import SelectColor from '@/shared/ui/scheduleTodo/SelectColor/SelectColor' type UseTodoFooterProps = { repeatConfig: RepeatConfig diff --git a/src/shared/types/schedule/types.ts b/src/shared/types/schedule/types.ts new file mode 100644 index 0000000..d62e23a --- /dev/null +++ b/src/shared/types/schedule/types.ts @@ -0,0 +1,46 @@ +export type PlaceResult = kakao.maps.services.PlacesSearchResultItem + +export type PlaceMarker = { + id: string + placeName: string + position: { + lat: number + lng: number + } +} + +export type SearchPlaceSelectionOptions = { + closeAfterSelect?: boolean + address?: string | null +} + +export type SearchPlaceProps = { + selectedLocation?: string + onSelectLocation: (location: string, options?: SearchPlaceSelectionOptions) => void +} + +export type SearchState = 'idle' | 'loading' | 'success' | 'zero' | 'error' +export type ResultsView = 'hidden' | 'inline' | 'expanded' + +export type SearchPlacePanelProps = { + panelTitle: string + panelCaption: string | null + panelMessage: string + panelPlaces: PlaceResult[] + recentSearches: string[] + selectedPlaceId: string | null + shouldShowRecentSearches: boolean + showExpandedResults: boolean + showPreviewResults: boolean + onRecentSearchClick: (value: string) => void + onRemoveRecentSearch: (value: string) => void + onSelectPlace: (place: PlaceResult) => void +} + +export type UseSearchPlaceParams = Pick< + SearchPlaceProps, + 'onSelectLocation' | 'selectedLocation' +> & { + isKakaoLoading: boolean + kakaoAppKey: string +} diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleDateTimeSection.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleDateTimeSection.tsx index dc9414c..4b246cc 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleDateTimeSection.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleDateTimeSection.tsx @@ -6,9 +6,9 @@ import { useScheduleCalendarOverlay } from '@/shared/hooks/addSchedule' import { useCalendarFieldPicker } from '@/shared/hooks/form/useCalendarFieldPicker' import type { ScheduleEditorFormValues } from '@/shared/types/event/event' import type { ScheduleEditorFormProps } from '@/shared/types/modal/scheduleEditor' +import CustomDatePicker from '@/shared/ui/calendar/CustomDatePicker/CustomDatePicker' +import CustomTimePicker from '@/shared/ui/calendar/CustomTimePicker/CustomTimePicker' import Checkbox from '@/shared/ui/common/Checkbox/Checkbox' -import CustomDatePicker from '@/shared/ui/common/CustomDatePicker/CustomDatePicker' -import CustomTimePicker from '@/shared/ui/common/CustomTimePicker/CustomTimePicker' import * as S from '@/shared/ui/Modals/ScheduleEditor/index.style' import { formatDisplayDate } from '@/shared/utils/date' diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleDetailsSection.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleDetailsSection.tsx index 0a435d1..c6b48ef 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleDetailsSection.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleDetailsSection.tsx @@ -5,8 +5,8 @@ import { useFormContext, useWatch } from 'react-hook-form' import { useSchedulePlaceOverlay } from '@/shared/hooks/addSchedule' import type { ScheduleEditorFormValues } from '@/shared/types/event/event' import type { ScheduleEditorFormProps } from '@/shared/types/modal/scheduleEditor' -import SearchPlace from '@/shared/ui/common/SearchPlace/SearchPlace' import * as S from '@/shared/ui/Modals/ScheduleEditor/index.style' +import SearchPlace from '@/shared/ui/scheduleTodo/SearchPlace/SearchPlace' type ScheduleDetailsSectionProps = Pick diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleRepeatSection.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleRepeatSection.tsx index 047e61c..1f090ea 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleRepeatSection.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleRepeatSection.tsx @@ -3,10 +3,10 @@ import { useFormContext, useWatch } from 'react-hook-form' import type { RepeatConfigSchema, ScheduleEditorFormValues } from '@/shared/types/event/event' import type { RepeatConfig, RepeatType } from '@/shared/types/recurrence/repeat' -import CustomBasisPanel from '@/shared/ui/common/CustomBasisPanel/CustomBasisPanel' -import RepeatTypeGroup from '@/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup' -import TerminationPanel from '@/shared/ui/common/TerminationPanel/TerminationPanel' +import CustomBasisPanel from '@/shared/ui/calendar/CustomBasisPanel/CustomBasisPanel' import * as S from '@/shared/ui/Modals/ScheduleEditor/index.style' +import RepeatTypeGroup from '@/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup' +import TerminationPanel from '@/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel' type ScheduleRepeatSectionProps = { updateConfig: (changes: Partial) => void diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleTitleField.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleTitleField.tsx index e184750..5d7f85f 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleTitleField.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleTitleField.tsx @@ -5,7 +5,7 @@ import { useFormContext, useWatch } from 'react-hook-form' import { useThrottledValue } from '@/shared/hooks/common/useThrottledValue' import { useEventTitleHistoryQuery } from '@/shared/hooks/query/useCalendarQueries' import type { ScheduleEditorFormValues } from '@/shared/types/event/event' -import TitleSuggestionInput from '@/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput' +import TitleSuggestionInput from '@/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput' type ScheduleTitleFieldProps = { portalTarget?: HTMLElement | null diff --git a/src/shared/ui/Modals/TodoEditor/TodoDateTimeSection.tsx b/src/shared/ui/Modals/TodoEditor/TodoDateTimeSection.tsx index 488d280..84d7c64 100644 --- a/src/shared/ui/Modals/TodoEditor/TodoDateTimeSection.tsx +++ b/src/shared/ui/Modals/TodoEditor/TodoDateTimeSection.tsx @@ -5,9 +5,9 @@ import { useFormContext, useWatch } from 'react-hook-form' import { useTodoCalendarOverlay } from '@/shared/hooks/addTodo' import type { TodoEditorFormValues } from '@/shared/types/event/event' import type { TodoEditorFormProps } from '@/shared/types/modal/todoEditor' +import CustomDatePicker from '@/shared/ui/calendar/CustomDatePicker/CustomDatePicker' +import CustomTimePicker from '@/shared/ui/calendar/CustomTimePicker/CustomTimePicker' import Checkbox from '@/shared/ui/common/Checkbox/Checkbox' -import CustomDatePicker from '@/shared/ui/common/CustomDatePicker/CustomDatePicker' -import CustomTimePicker from '@/shared/ui/common/CustomTimePicker/CustomTimePicker' import * as S from '@/shared/ui/Modals/TodoEditor/index.style' import { formatDisplayDate } from '@/shared/utils/date' diff --git a/src/shared/ui/Modals/TodoEditor/TodoRepeatSection.tsx b/src/shared/ui/Modals/TodoEditor/TodoRepeatSection.tsx index fc4f519..0b446cb 100644 --- a/src/shared/ui/Modals/TodoEditor/TodoRepeatSection.tsx +++ b/src/shared/ui/Modals/TodoEditor/TodoRepeatSection.tsx @@ -3,10 +3,10 @@ import { useFormContext, useWatch } from 'react-hook-form' import type { RepeatConfigSchema, TodoEditorFormValues } from '@/shared/types/event/event' import type { RepeatConfig, RepeatType } from '@/shared/types/recurrence/repeat' -import CustomBasisPanel from '@/shared/ui/common/CustomBasisPanel/CustomBasisPanel' -import RepeatTypeGroup from '@/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup' -import TerminationPanel from '@/shared/ui/common/TerminationPanel/TerminationPanel' +import CustomBasisPanel from '@/shared/ui/calendar/CustomBasisPanel/CustomBasisPanel' import * as S from '@/shared/ui/Modals/TodoEditor/index.style' +import RepeatTypeGroup from '@/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup' +import TerminationPanel from '@/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel' type TodoRepeatSectionProps = { updateConfig: (changes: Partial) => void diff --git a/src/shared/ui/Modals/TodoEditor/TodoTitleField.tsx b/src/shared/ui/Modals/TodoEditor/TodoTitleField.tsx index 0192299..03412c2 100644 --- a/src/shared/ui/Modals/TodoEditor/TodoTitleField.tsx +++ b/src/shared/ui/Modals/TodoEditor/TodoTitleField.tsx @@ -6,7 +6,7 @@ import { useThrottledValue } from '@/shared/hooks/common/useThrottledValue' import { useGetTodoTitleHistoryQuery } from '@/shared/hooks/query/useTodoQueries' import type { CalendarEvent } from '@/shared/types/calendar/types' import type { TodoEditorFormValues } from '@/shared/types/event/event' -import TitleSuggestionInput from '@/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput' +import TitleSuggestionInput from '@/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput' type TodoTitleFieldProps = { eventId: CalendarEvent['id'] diff --git a/src/shared/ui/common/CustomBasisPanel/CustomBasisPanel.tsx b/src/shared/ui/calendar/CustomBasisPanel/CustomBasisPanel.tsx similarity index 97% rename from src/shared/ui/common/CustomBasisPanel/CustomBasisPanel.tsx rename to src/shared/ui/calendar/CustomBasisPanel/CustomBasisPanel.tsx index 33c1c4a..9f1cd58 100644 --- a/src/shared/ui/common/CustomBasisPanel/CustomBasisPanel.tsx +++ b/src/shared/ui/calendar/CustomBasisPanel/CustomBasisPanel.tsx @@ -8,7 +8,7 @@ import { MonthlyRepeatPanel, WeeklyRepeatPanel, YearlyRepeatPanel, -} from '@/shared/ui/common/RepeatPanel' +} from '@/shared/ui/scheduleTodo/RepeatPanel' type Props = { config: RepeatConfigSchema diff --git a/src/shared/ui/common/CustomDatePicker/CustomDatePicker.style.ts b/src/shared/ui/calendar/CustomDatePicker/CustomDatePicker.style.ts similarity index 100% rename from src/shared/ui/common/CustomDatePicker/CustomDatePicker.style.ts rename to src/shared/ui/calendar/CustomDatePicker/CustomDatePicker.style.ts diff --git a/src/shared/ui/common/CustomDatePicker/CustomDatePicker.tsx b/src/shared/ui/calendar/CustomDatePicker/CustomDatePicker.tsx similarity index 100% rename from src/shared/ui/common/CustomDatePicker/CustomDatePicker.tsx rename to src/shared/ui/calendar/CustomDatePicker/CustomDatePicker.tsx diff --git a/src/shared/ui/common/CustomTimePicker/CustomTimePicker.style.ts b/src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.style.ts similarity index 100% rename from src/shared/ui/common/CustomTimePicker/CustomTimePicker.style.ts rename to src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.style.ts diff --git a/src/shared/ui/common/CustomTimePicker/CustomTimePicker.tsx b/src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.tsx similarity index 100% rename from src/shared/ui/common/CustomTimePicker/CustomTimePicker.tsx rename to src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.tsx diff --git a/src/shared/ui/common/SearchPlace/SearchPlace.types.ts b/src/shared/ui/common/SearchPlace/SearchPlace.types.ts deleted file mode 100644 index f9dcdcf..0000000 --- a/src/shared/ui/common/SearchPlace/SearchPlace.types.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type PlaceResult = kakao.maps.services.PlacesSearchResultItem - -export type PlaceMarker = { - id: string - placeName: string - position: { - lat: number - lng: number - } -} - -export type SearchPlaceSelectionOptions = { - closeAfterSelect?: boolean - address?: string | null -} - -export type SearchPlaceProps = { - selectedLocation?: string - onSelectLocation: (location: string, options?: SearchPlaceSelectionOptions) => void -} - -export type SearchState = 'idle' | 'loading' | 'success' | 'zero' | 'error' -export type ResultsView = 'hidden' | 'inline' | 'expanded' diff --git a/src/shared/ui/common/SearchPlace/useSearchPlace.ts b/src/shared/ui/common/SearchPlace/useSearchPlace.ts deleted file mode 100644 index 7da6e93..0000000 --- a/src/shared/ui/common/SearchPlace/useSearchPlace.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' - -import type { - PlaceMarker, - PlaceResult, - ResultsView, - SearchPlaceProps, - SearchState, -} from './SearchPlace.types' -import { - AUTO_SEARCH_DELAY_MS, - getPlaceAddressValue, - getPlaceLocationValue, - MAX_RECENT_SEARCHES, - readRecentSearches, - toMarker, - writeRecentSearches, -} from './SearchPlace.utils' - -type UseSearchPlaceParams = Pick & { - isKakaoLoading: boolean - kakaoAppKey: string -} - -const useSearchPlace = ({ - onSelectLocation, - selectedLocation = '', - isKakaoLoading, - kakaoAppKey, -}: UseSearchPlaceParams) => { - const trimmedSelectedLocation = selectedLocation.trim() - const [keyword, setKeyword] = useState(selectedLocation) - const [map, setMap] = useState(null) - const [markers, setMarkers] = useState([]) - const [places, setPlaces] = useState([]) - const [selectedPlaceId, setSelectedPlaceId] = useState(null) - const [recentSearches, setRecentSearches] = useState(() => readRecentSearches()) - const [searchState, setSearchState] = useState('idle') - const [resultsView, setResultsView] = useState('hidden') - const [isSearching, setIsSearching] = useState(false) - const [statusMessage, setStatusMessage] = useState('') - const restoredLocationRef = useRef('') - const lastRequestedKeywordRef = useRef('') - const requestIdRef = useRef(0) - const trimmedKeyword = keyword.trim() - const inlinePlaces = places.slice(0, 4) - const isTypedLocationSelected = - trimmedKeyword.length > 0 && - trimmedKeyword === trimmedSelectedLocation && - selectedPlaceId === null - const showPreviewResults = - resultsView === 'inline' && searchState === 'success' && inlinePlaces.length > 0 - const showExpandedResults = - resultsView === 'expanded' && searchState === 'success' && places.length > 0 - const shouldShowRecentSearches = resultsView === 'hidden' && recentSearches.length > 0 - const hasSelectedPlacePreview = - resultsView === 'hidden' && - searchState === 'success' && - selectedPlaceId !== null && - trimmedSelectedLocation.length > 0 - const panelTitle = showExpandedResults - ? '검색 결과' - : showPreviewResults - ? '자동 검색' - : hasSelectedPlacePreview - ? '선택된 장소' - : shouldShowRecentSearches - ? '최근 검색어' - : trimmedKeyword - ? '검색 상태' - : '장소 찾기' - const panelCaption = showExpandedResults - ? `${places.length}개` - : showPreviewResults - ? `${inlinePlaces.length}개 미리보기` - : hasSelectedPlacePreview - ? '지도에 표시 중' - : shouldShowRecentSearches - ? `최대 ${MAX_RECENT_SEARCHES}개` - : null - const panelMessage = - resultsView === 'expanded' || resultsView === 'inline' - ? statusMessage - : hasSelectedPlacePreview - ? '현재 선택된 장소를 기준으로 지도를 보여주고 있습니다.' - : '최근 검색어가 없습니다.' - const panelPlaces = showExpandedResults ? places : inlinePlaces - - const saveRecentSearch = useCallback((value: string) => { - setRecentSearches((previous) => { - const next = [value, ...previous.filter((item) => item !== value)].slice( - 0, - MAX_RECENT_SEARCHES, - ) - - writeRecentSearches(next) - return next - }) - }, []) - - const removeRecentSearch = useCallback((value: string) => { - setRecentSearches((previous) => { - const next = previous.filter((item) => item !== value) - writeRecentSearches(next) - return next - }) - }, []) - - const selectPlace = useCallback( - (place: PlaceResult) => { - const nextLocation = getPlaceLocationValue(place) - const nextAddress = getPlaceAddressValue(place) - setSelectedPlaceId(place.id) - setKeyword(nextLocation) - setSearchState('success') - saveRecentSearch(trimmedKeyword || nextLocation) - onSelectLocation(nextLocation, { closeAfterSelect: true, address: nextAddress }) - - if (!map) return - map.panTo(new kakao.maps.LatLng(Number(place.y), Number(place.x))) - map.setLevel(3) - }, - [map, onSelectLocation, saveRecentSearch, trimmedKeyword], - ) - - const searchPlaces = useCallback( - (rawKeyword: string, options?: { view?: ResultsView }) => { - const nextKeyword = rawKeyword.trim() - const nextView = options?.view ?? 'expanded' - - if (!nextKeyword) { - setPlaces([]) - setMarkers([]) - setSelectedPlaceId(null) - setSearchState('idle') - setResultsView('hidden') - setStatusMessage('검색어를 입력해주세요.') - return - } - - if (!kakaoAppKey) { - setSearchState('error') - setResultsView(nextView) - setStatusMessage('카카오맵 API 키가 설정되지 않았습니다.') - return - } - - if (isKakaoLoading || typeof kakao === 'undefined' || !kakao.maps.services) { - setSearchState('loading') - setResultsView(nextView) - setStatusMessage('지도를 불러오는 중입니다. 잠시 후 다시 시도해주세요.') - return - } - - const requestId = requestIdRef.current + 1 - requestIdRef.current = requestId - lastRequestedKeywordRef.current = nextKeyword - setIsSearching(true) - setSearchState('loading') - setResultsView(nextView) - setStatusMessage(`"${nextKeyword}" 검색 중...`) - - const placesService = new kakao.maps.services.Places() - placesService.keywordSearch(nextKeyword, (result, status) => { - if (requestId !== requestIdRef.current) return - - setIsSearching(false) - - if (status === kakao.maps.services.Status.OK) { - const nextMarkers = result.map(toMarker) - const matchedPlace = result.find( - (place) => getPlaceLocationValue(place) === trimmedSelectedLocation, - ) - - setPlaces(result) - setMarkers(nextMarkers) - setSelectedPlaceId(matchedPlace?.id ?? null) - setSearchState('success') - setStatusMessage( - nextView === 'expanded' - ? `${result.length}개의 장소를 찾았습니다.` - : `${result.length}개 장소가 검색되었습니다.`, - ) - return - } - - setPlaces([]) - setMarkers([]) - setSelectedPlaceId(null) - - if (status === kakao.maps.services.Status.ZERO_RESULT) { - setSearchState('zero') - setStatusMessage('검색 결과가 없습니다.') - return - } - - setSearchState('error') - setStatusMessage('장소 검색 중 오류가 발생했습니다. 다시 시도해주세요.') - }) - }, - [isKakaoLoading, kakaoAppKey, trimmedSelectedLocation], - ) - - useEffect(() => { - if (!map || markers.length === 0 || typeof kakao === 'undefined') return - - const bounds = new kakao.maps.LatLngBounds() - - markers.forEach((marker) => { - bounds.extend(new kakao.maps.LatLng(marker.position.lat, marker.position.lng)) - }) - - map.setBounds(bounds) - }, [map, markers]) - - const handleUseTypedLocation = useCallback(() => { - if (!trimmedKeyword) return - - setSelectedPlaceId(null) - setSearchState('zero') - setResultsView('hidden') - saveRecentSearch(trimmedKeyword) - onSelectLocation(trimmedKeyword, { closeAfterSelect: true, address: null }) - }, [onSelectLocation, saveRecentSearch, trimmedKeyword]) - - const handleSubmit = useCallback(() => { - if (!trimmedKeyword) return - - if ( - lastRequestedKeywordRef.current === trimmedKeyword && - (searchState === 'success' || searchState === 'zero') - ) { - saveRecentSearch(trimmedKeyword) - setResultsView('expanded') - return - } - - saveRecentSearch(trimmedKeyword) - searchPlaces(trimmedKeyword, { view: 'expanded' }) - }, [saveRecentSearch, searchPlaces, searchState, trimmedKeyword]) - - const handleMarkerClick = useCallback( - (markerId: string) => { - const place = places.find((item) => item.id === markerId) - if (!place) return - selectPlace(place) - }, - [places, selectPlace], - ) - - const handleRecentSearchClick = useCallback( - (value: string) => { - setKeyword(value) - saveRecentSearch(value) - searchPlaces(value, { view: 'expanded' }) - }, - [saveRecentSearch, searchPlaces], - ) - - const handleKeywordChange = useCallback((nextValue: string) => { - setKeyword(nextValue) - setSelectedPlaceId(null) - setResultsView('hidden') - setIsSearching(false) - requestIdRef.current += 1 - restoredLocationRef.current = '' - lastRequestedKeywordRef.current = '' - - if (nextValue.trim()) { - setPlaces([]) - setMarkers([]) - setSearchState('idle') - setStatusMessage('') - return - } - - setPlaces([]) - setMarkers([]) - setSearchState('idle') - setStatusMessage('장소명을 입력해주세요.') - }, []) - - useEffect(() => { - if (!map || !trimmedSelectedLocation || isKakaoLoading) return - if (restoredLocationRef.current === trimmedSelectedLocation) return - if (lastRequestedKeywordRef.current === trimmedSelectedLocation) { - restoredLocationRef.current = trimmedSelectedLocation - return - } - - const timeoutId = window.setTimeout(() => { - restoredLocationRef.current = trimmedSelectedLocation - searchPlaces(trimmedSelectedLocation, { view: 'hidden' }) - }, 0) - - return () => window.clearTimeout(timeoutId) - }, [isKakaoLoading, map, searchPlaces, trimmedSelectedLocation]) - - useEffect(() => { - if (!trimmedKeyword || isKakaoLoading) return - if ( - trimmedKeyword === trimmedSelectedLocation && - restoredLocationRef.current === trimmedSelectedLocation - ) { - return - } - if (lastRequestedKeywordRef.current === trimmedKeyword) return - - const timeoutId = window.setTimeout(() => { - searchPlaces(trimmedKeyword, { view: 'inline' }) - }, AUTO_SEARCH_DELAY_MS) - - return () => window.clearTimeout(timeoutId) - }, [isKakaoLoading, searchPlaces, trimmedKeyword, trimmedSelectedLocation]) - - const handleMapCreate = useCallback((createdMap: kakao.maps.Map) => { - setMap(createdMap) - }, []) - - return { - handleKeywordChange, - handleMapCreate, - handleMarkerClick, - handleRecentSearchClick, - handleSubmit, - handleUseTypedLocation, - isSearching, - isTypedLocationSelected, - keyword, - markers, - panelCaption, - panelMessage, - panelPlaces, - panelTitle, - recentSearches, - removeRecentSearch, - selectedPlaceId, - showExpandedResults, - showPreviewResults, - shouldShowRecentSearches, - trimmedKeyword, - selectPlace, - } -} - -export default useSearchPlace diff --git a/src/shared/ui/common/RecentSearch/RecentSearch.style.ts b/src/shared/ui/scheduleTodo/RecentSearch/RecentSearch.style.ts similarity index 100% rename from src/shared/ui/common/RecentSearch/RecentSearch.style.ts rename to src/shared/ui/scheduleTodo/RecentSearch/RecentSearch.style.ts diff --git a/src/shared/ui/common/RecentSearch/RecentSearch.tsx b/src/shared/ui/scheduleTodo/RecentSearch/RecentSearch.tsx similarity index 100% rename from src/shared/ui/common/RecentSearch/RecentSearch.tsx rename to src/shared/ui/scheduleTodo/RecentSearch/RecentSearch.tsx diff --git a/src/shared/ui/common/RepeatPanel/DailyRepeatPanel.tsx b/src/shared/ui/scheduleTodo/RepeatPanel/DailyRepeatPanel.tsx similarity index 100% rename from src/shared/ui/common/RepeatPanel/DailyRepeatPanel.tsx rename to src/shared/ui/scheduleTodo/RepeatPanel/DailyRepeatPanel.tsx diff --git a/src/shared/ui/common/RepeatPanel/MonthlyRepeatPanel.tsx b/src/shared/ui/scheduleTodo/RepeatPanel/MonthlyRepeatPanel.tsx similarity index 100% rename from src/shared/ui/common/RepeatPanel/MonthlyRepeatPanel.tsx rename to src/shared/ui/scheduleTodo/RepeatPanel/MonthlyRepeatPanel.tsx diff --git a/src/shared/ui/common/RepeatPanel/RepeatPanel.style.ts b/src/shared/ui/scheduleTodo/RepeatPanel/RepeatPanel.style.ts similarity index 100% rename from src/shared/ui/common/RepeatPanel/RepeatPanel.style.ts rename to src/shared/ui/scheduleTodo/RepeatPanel/RepeatPanel.style.ts diff --git a/src/shared/ui/common/RepeatPanel/WeeklyRepeatPanel.tsx b/src/shared/ui/scheduleTodo/RepeatPanel/WeeklyRepeatPanel.tsx similarity index 100% rename from src/shared/ui/common/RepeatPanel/WeeklyRepeatPanel.tsx rename to src/shared/ui/scheduleTodo/RepeatPanel/WeeklyRepeatPanel.tsx diff --git a/src/shared/ui/common/RepeatPanel/YearlyRepeatPanel.tsx b/src/shared/ui/scheduleTodo/RepeatPanel/YearlyRepeatPanel.tsx similarity index 100% rename from src/shared/ui/common/RepeatPanel/YearlyRepeatPanel.tsx rename to src/shared/ui/scheduleTodo/RepeatPanel/YearlyRepeatPanel.tsx diff --git a/src/shared/ui/common/RepeatPanel/index.ts b/src/shared/ui/scheduleTodo/RepeatPanel/index.ts similarity index 100% rename from src/shared/ui/common/RepeatPanel/index.ts rename to src/shared/ui/scheduleTodo/RepeatPanel/index.ts diff --git a/src/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup.style.ts b/src/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup.style.ts similarity index 100% rename from src/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup.style.ts rename to src/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup.style.ts diff --git a/src/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup.tsx b/src/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup.tsx similarity index 100% rename from src/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup.tsx rename to src/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup.tsx diff --git a/src/shared/ui/common/SearchPlace/SearchPlace.style.ts b/src/shared/ui/scheduleTodo/SearchPlace/SearchPlace.style.ts similarity index 100% rename from src/shared/ui/common/SearchPlace/SearchPlace.style.ts rename to src/shared/ui/scheduleTodo/SearchPlace/SearchPlace.style.ts diff --git a/src/shared/ui/common/SearchPlace/SearchPlace.tsx b/src/shared/ui/scheduleTodo/SearchPlace/SearchPlace.tsx similarity index 96% rename from src/shared/ui/common/SearchPlace/SearchPlace.tsx rename to src/shared/ui/scheduleTodo/SearchPlace/SearchPlace.tsx index dbb2dc7..508b934 100644 --- a/src/shared/ui/common/SearchPlace/SearchPlace.tsx +++ b/src/shared/ui/scheduleTodo/SearchPlace/SearchPlace.tsx @@ -2,12 +2,12 @@ import { Map, MapMarker, useKakaoLoader } from 'react-kakao-maps-sdk' import Check from '@/shared/assets/icons/check.svg?react' import Search from '@/shared/assets/icons/search.svg?react' +import type { SearchPlaceProps } from '@/shared/types/schedule/types' +import { DEFAULT_CENTER } from '@/shared/utils/searchPlace' +import useSearchPlace from '../../../utils/useSearchPlace' import * as S from './SearchPlace.style' -import type { SearchPlaceProps } from './SearchPlace.types' -import { DEFAULT_CENTER } from './SearchPlace.utils' import SearchPlacePanel from './SearchPlacePanel' -import useSearchPlace from './useSearchPlace' const SearchPlace = ({ selectedLocation = '', onSelectLocation }: SearchPlaceProps) => { const kakaoAppKey = import.meta.env.VITE_KAKAO_API ?? '' diff --git a/src/shared/ui/common/SearchPlace/SearchPlacePanel.tsx b/src/shared/ui/scheduleTodo/SearchPlace/SearchPlacePanel.tsx similarity index 77% rename from src/shared/ui/common/SearchPlace/SearchPlacePanel.tsx rename to src/shared/ui/scheduleTodo/SearchPlace/SearchPlacePanel.tsx index ff8f069..9c4b85b 100644 --- a/src/shared/ui/common/SearchPlace/SearchPlacePanel.tsx +++ b/src/shared/ui/scheduleTodo/SearchPlace/SearchPlacePanel.tsx @@ -1,23 +1,8 @@ -import RecentSearch from '@/shared/ui/common/RecentSearch/RecentSearch' +import type { SearchPlacePanelProps } from '@/shared/types/schedule/types' +import RecentSearch from '@/shared/ui/scheduleTodo/RecentSearch/RecentSearch' +import { getPlaceAddressValue } from '@/shared/utils/searchPlace' import * as S from './SearchPlace.style' -import type { PlaceResult } from './SearchPlace.types' -import { getPlaceAddressValue } from './SearchPlace.utils' - -type SearchPlacePanelProps = { - panelTitle: string - panelCaption: string | null - panelMessage: string - panelPlaces: PlaceResult[] - recentSearches: string[] - selectedPlaceId: string | null - shouldShowRecentSearches: boolean - showExpandedResults: boolean - showPreviewResults: boolean - onRecentSearchClick: (value: string) => void - onRemoveRecentSearch: (value: string) => void - onSelectPlace: (place: PlaceResult) => void -} const SearchPlacePanel = ({ panelTitle, diff --git a/src/shared/ui/common/SelectColor/SelectColor.style.ts b/src/shared/ui/scheduleTodo/SelectColor/SelectColor.style.ts similarity index 100% rename from src/shared/ui/common/SelectColor/SelectColor.style.ts rename to src/shared/ui/scheduleTodo/SelectColor/SelectColor.style.ts diff --git a/src/shared/ui/common/SelectColor/SelectColor.tsx b/src/shared/ui/scheduleTodo/SelectColor/SelectColor.tsx similarity index 100% rename from src/shared/ui/common/SelectColor/SelectColor.tsx rename to src/shared/ui/scheduleTodo/SelectColor/SelectColor.tsx diff --git a/src/shared/ui/common/TerminationPanel/TerminationPanel.style.ts b/src/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel.style.ts similarity index 100% rename from src/shared/ui/common/TerminationPanel/TerminationPanel.style.ts rename to src/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel.style.ts diff --git a/src/shared/ui/common/TerminationPanel/TerminationPanel.tsx b/src/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel.tsx similarity index 98% rename from src/shared/ui/common/TerminationPanel/TerminationPanel.tsx rename to src/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel.tsx index 38b3c79..7935b95 100644 --- a/src/shared/ui/common/TerminationPanel/TerminationPanel.tsx +++ b/src/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel.tsx @@ -11,8 +11,8 @@ import { createPortal } from 'react-dom' import { theme } from '@/shared/styles/theme' import type { RepeatConfigSchema } from '@/shared/types/event/event' import type { RepeatConfig } from '@/shared/types/recurrence/repeat' +import CustomDatePicker from '@/shared/ui/calendar/CustomDatePicker/CustomDatePicker' import Checkbox from '@/shared/ui/common/Checkbox/Checkbox' -import CustomDatePicker from '@/shared/ui/common/CustomDatePicker/CustomDatePicker' import { formatIsoDate } from '@/shared/utils/date' import * as S from './TerminationPanel.style' diff --git a/src/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput.style.ts b/src/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput.style.ts similarity index 100% rename from src/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput.style.ts rename to src/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput.style.ts diff --git a/src/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput.tsx b/src/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput.tsx similarity index 100% rename from src/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput.tsx rename to src/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput.tsx diff --git a/src/shared/ui/common/SearchPlace/SearchPlace.utils.ts b/src/shared/utils/searchPlace.ts similarity index 95% rename from src/shared/ui/common/SearchPlace/SearchPlace.utils.ts rename to src/shared/utils/searchPlace.ts index f232f56..3390ae3 100644 --- a/src/shared/ui/common/SearchPlace/SearchPlace.utils.ts +++ b/src/shared/utils/searchPlace.ts @@ -1,4 +1,4 @@ -import type { PlaceMarker, PlaceResult } from './SearchPlace.types' +import type { PlaceMarker, PlaceResult } from '../types/schedule/types' export const DEFAULT_CENTER = { lat: 37.566826, diff --git a/src/shared/utils/usePlaceSearch.ts b/src/shared/utils/usePlaceSearch.ts new file mode 100644 index 0000000..8615215 --- /dev/null +++ b/src/shared/utils/usePlaceSearch.ts @@ -0,0 +1,216 @@ +import { useCallback, useReducer, useRef } from 'react' + +import type { PlaceResult, ResultsView, SearchState } from '@/shared/types/schedule/types' +import { getPlaceLocationValue } from '@/shared/utils/searchPlace' + +type SearchPlacesOptions = { + view?: ResultsView +} + +type PlaceSearchState = { + issue: 'missing_key' | 'sdk_loading' | 'search_failed' | null + places: PlaceResult[] + requestedKeyword: string + resultsView: ResultsView + searchState: SearchState + selectedPlaceId: string | null +} + +type PlaceSearchAction = + | { type: 'clear' } + | { type: 'expand_results' } + | { type: 'loading'; view: ResultsView; keyword: string } + | { type: 'missing_key'; view: ResultsView } + | { type: 'sdk_loading'; view: ResultsView } + | { type: 'success'; places: PlaceResult[]; selectedPlaceId: string | null } + | { type: 'zero_result' } + | { type: 'error' } + | { type: 'select_place'; placeId: string } + | { type: 'use_typed_location' } + +const initialPlaceSearchState: PlaceSearchState = { + issue: null, + places: [], + requestedKeyword: '', + resultsView: 'hidden', + searchState: 'idle', + selectedPlaceId: null, +} + +const placeSearchReducer = ( + state: PlaceSearchState, + action: PlaceSearchAction, +): PlaceSearchState => { + switch (action.type) { + case 'clear': + return initialPlaceSearchState + case 'expand_results': + return { ...state, resultsView: 'expanded' } + case 'loading': + return { + ...state, + requestedKeyword: action.keyword, + resultsView: action.view, + searchState: 'loading', + issue: null, + } + case 'missing_key': + return { + ...state, + resultsView: action.view, + searchState: 'error', + issue: 'missing_key', + } + case 'sdk_loading': + return { + ...state, + requestedKeyword: '', + resultsView: action.view, + searchState: 'loading', + issue: 'sdk_loading', + } + case 'success': + return { + ...state, + issue: null, + places: action.places, + searchState: 'success', + selectedPlaceId: action.selectedPlaceId, + } + case 'zero_result': + return { + ...state, + issue: null, + places: [], + searchState: 'zero', + selectedPlaceId: null, + } + case 'error': + return { + ...state, + issue: 'search_failed', + places: [], + searchState: 'error', + selectedPlaceId: null, + } + case 'select_place': + return { + ...state, + issue: null, + searchState: 'success', + selectedPlaceId: action.placeId, + } + case 'use_typed_location': + return { + ...state, + issue: null, + resultsView: 'hidden', + searchState: 'zero', + selectedPlaceId: null, + } + } +} + +type UsePlaceSearchParams = { + isKakaoLoading: boolean + kakaoAppKey: string + selectedLocation: string +} + +const usePlaceSearch = ({ + isKakaoLoading, + kakaoAppKey, + selectedLocation, +}: UsePlaceSearchParams) => { + const [state, dispatch] = useReducer(placeSearchReducer, initialPlaceSearchState) + const lastRequestedKeywordRef = useRef('') + const requestIdRef = useRef(0) + + const cancelPendingSearch = useCallback(() => { + requestIdRef.current += 1 + lastRequestedKeywordRef.current = '' + }, []) + + const clearSearch = useCallback(() => { + dispatch({ type: 'clear' }) + }, []) + + const expandResults = useCallback(() => { + dispatch({ type: 'expand_results' }) + }, []) + + const markTypedLocationSelected = useCallback(() => { + dispatch({ type: 'use_typed_location' }) + }, []) + + const selectPlaceInSearch = useCallback((placeId: string) => { + dispatch({ type: 'select_place', placeId }) + }, []) + + const searchPlaces = useCallback( + (rawKeyword: string, options?: SearchPlacesOptions) => { + const nextKeyword = rawKeyword.trim() + const nextView = options?.view ?? 'expanded' + + if (!nextKeyword) { + dispatch({ type: 'clear' }) + return + } + + if (!kakaoAppKey) { + dispatch({ type: 'missing_key', view: nextView }) + return + } + + if (isKakaoLoading || typeof kakao === 'undefined' || !kakao.maps.services) { + dispatch({ type: 'sdk_loading', view: nextView }) + return + } + + const requestId = requestIdRef.current + 1 + requestIdRef.current = requestId + lastRequestedKeywordRef.current = nextKeyword + dispatch({ type: 'loading', view: nextView, keyword: nextKeyword }) + + const placesService = new kakao.maps.services.Places() + placesService.keywordSearch(nextKeyword, (result, status) => { + if (requestId !== requestIdRef.current) return + + if (status === kakao.maps.services.Status.OK) { + const matchedPlace = result.find( + (place) => getPlaceLocationValue(place) === selectedLocation, + ) + + dispatch({ + type: 'success', + places: result, + selectedPlaceId: matchedPlace?.id ?? null, + }) + return + } + + if (status === kakao.maps.services.Status.ZERO_RESULT) { + dispatch({ type: 'zero_result' }) + return + } + + dispatch({ type: 'error' }) + }) + }, + [isKakaoLoading, kakaoAppKey, selectedLocation], + ) + + return { + ...state, + cancelPendingSearch, + clearSearch, + expandResults, + isSearching: state.searchState === 'loading', + lastRequestedKeywordRef, + markTypedLocationSelected, + searchPlaces, + selectPlaceInSearch, + } +} + +export default usePlaceSearch diff --git a/src/shared/utils/useRecentPlaceSearches.ts b/src/shared/utils/useRecentPlaceSearches.ts new file mode 100644 index 0000000..22df961 --- /dev/null +++ b/src/shared/utils/useRecentPlaceSearches.ts @@ -0,0 +1,39 @@ +import { useCallback, useState } from 'react' + +import { + MAX_RECENT_SEARCHES, + readRecentSearches, + writeRecentSearches, +} from '@/shared/utils/searchPlace' + +const useRecentPlaceSearches = () => { + const [recentSearches, setRecentSearches] = useState(() => readRecentSearches()) + + const saveRecentSearch = useCallback((value: string) => { + setRecentSearches((previous) => { + const next = [value, ...previous.filter((item) => item !== value)].slice( + 0, + MAX_RECENT_SEARCHES, + ) + + writeRecentSearches(next) + return next + }) + }, []) + + const removeRecentSearch = useCallback((value: string) => { + setRecentSearches((previous) => { + const next = previous.filter((item) => item !== value) + writeRecentSearches(next) + return next + }) + }, []) + + return { + recentSearches, + removeRecentSearch, + saveRecentSearch, + } +} + +export default useRecentPlaceSearches diff --git a/src/shared/utils/useSearchPlace.ts b/src/shared/utils/useSearchPlace.ts new file mode 100644 index 0000000..e188172 --- /dev/null +++ b/src/shared/utils/useSearchPlace.ts @@ -0,0 +1,228 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import type { PlaceResult, UseSearchPlaceParams } from '@/shared/types/schedule/types' +import { + AUTO_SEARCH_DELAY_MS, + getPlaceAddressValue, + getPlaceLocationValue, + toMarker, +} from '@/shared/utils/searchPlace' + +import usePlaceSearch from './usePlaceSearch' +import useRecentPlaceSearches from './useRecentPlaceSearches' +import useSearchPlacePanelModel from './useSearchPlacePanelModel' + +const useSearchPlace = ({ + onSelectLocation, + selectedLocation = '', + isKakaoLoading, + kakaoAppKey, +}: UseSearchPlaceParams) => { + const trimmedSelectedLocation = selectedLocation.trim() + const [keyword, setKeyword] = useState(selectedLocation) + const [map, setMap] = useState(null) + const restoredLocationRef = useRef('') + const trimmedKeyword = keyword.trim() + const { recentSearches, removeRecentSearch, saveRecentSearch } = useRecentPlaceSearches() + const { + cancelPendingSearch, + clearSearch, + expandResults, + issue, + isSearching, + lastRequestedKeywordRef, + markTypedLocationSelected, + places, + requestedKeyword, + resultsView, + searchPlaces, + searchState, + selectedPlaceId, + selectPlaceInSearch, + } = usePlaceSearch({ + isKakaoLoading, + kakaoAppKey, + selectedLocation: trimmedSelectedLocation, + }) + const markers = useMemo(() => places.map(toMarker), [places]) + const isTypedLocationSelected = + trimmedKeyword.length > 0 && + trimmedKeyword === trimmedSelectedLocation && + selectedPlaceId === null + const { + panelCaption, + panelMessage, + panelPlaces, + panelTitle, + showExpandedResults, + showPreviewResults, + shouldShowRecentSearches, + } = useSearchPlacePanelModel({ + places, + issue, + recentSearches, + requestedKeyword, + resultsView, + searchState, + selectedPlaceId, + trimmedKeyword, + trimmedSelectedLocation, + }) + + const selectPlace = useCallback( + (place: PlaceResult) => { + const nextLocation = getPlaceLocationValue(place) + const nextAddress = getPlaceAddressValue(place) + selectPlaceInSearch(place.id) + setKeyword(nextLocation) + saveRecentSearch(trimmedKeyword || nextLocation) + onSelectLocation(nextLocation, { closeAfterSelect: true, address: nextAddress }) + + if (!map) return + map.panTo(new kakao.maps.LatLng(Number(place.y), Number(place.x))) + map.setLevel(3) + }, + [map, onSelectLocation, saveRecentSearch, selectPlaceInSearch, trimmedKeyword], + ) + + useEffect(() => { + if (!map || markers.length === 0 || typeof kakao === 'undefined') return + + const bounds = new kakao.maps.LatLngBounds() + + markers.forEach((marker) => { + bounds.extend(new kakao.maps.LatLng(marker.position.lat, marker.position.lng)) + }) + + map.setBounds(bounds) + }, [map, markers]) + + const handleUseTypedLocation = useCallback(() => { + if (!trimmedKeyword) return + + markTypedLocationSelected() + saveRecentSearch(trimmedKeyword) + onSelectLocation(trimmedKeyword, { closeAfterSelect: true, address: null }) + }, [markTypedLocationSelected, onSelectLocation, saveRecentSearch, trimmedKeyword]) + + const handleSubmit = useCallback(() => { + if (!trimmedKeyword) return + + if ( + lastRequestedKeywordRef.current === trimmedKeyword && + (searchState === 'success' || searchState === 'zero') + ) { + saveRecentSearch(trimmedKeyword) + expandResults() + return + } + + saveRecentSearch(trimmedKeyword) + searchPlaces(trimmedKeyword, { view: 'expanded' }) + }, [ + expandResults, + lastRequestedKeywordRef, + saveRecentSearch, + searchPlaces, + searchState, + trimmedKeyword, + ]) + + const handleMarkerClick = useCallback( + (markerId: string) => { + const place = places.find((item) => item.id === markerId) + if (!place) return + selectPlace(place) + }, + [places, selectPlace], + ) + + const handleRecentSearchClick = useCallback( + (value: string) => { + setKeyword(value) + saveRecentSearch(value) + searchPlaces(value, { view: 'expanded' }) + }, + [saveRecentSearch, searchPlaces], + ) + + const handleKeywordChange = useCallback( + (nextValue: string) => { + setKeyword(nextValue) + clearSearch() + cancelPendingSearch() + restoredLocationRef.current = '' + }, + [cancelPendingSearch, clearSearch], + ) + + useEffect(() => { + if (!map || !trimmedSelectedLocation || isKakaoLoading) return + if (restoredLocationRef.current === trimmedSelectedLocation) return + if (lastRequestedKeywordRef.current === trimmedSelectedLocation) { + restoredLocationRef.current = trimmedSelectedLocation + return + } + + const timeoutId = window.setTimeout(() => { + restoredLocationRef.current = trimmedSelectedLocation + searchPlaces(trimmedSelectedLocation, { view: 'hidden' }) + }, 0) + + return () => window.clearTimeout(timeoutId) + }, [isKakaoLoading, lastRequestedKeywordRef, map, searchPlaces, trimmedSelectedLocation]) + + useEffect(() => { + if (!trimmedKeyword || isKakaoLoading) return + if ( + trimmedKeyword === trimmedSelectedLocation && + restoredLocationRef.current === trimmedSelectedLocation + ) { + return + } + if (lastRequestedKeywordRef.current === trimmedKeyword) return + + const timeoutId = window.setTimeout(() => { + searchPlaces(trimmedKeyword, { view: 'inline' }) + }, AUTO_SEARCH_DELAY_MS) + + return () => window.clearTimeout(timeoutId) + }, [ + isKakaoLoading, + lastRequestedKeywordRef, + searchPlaces, + trimmedKeyword, + trimmedSelectedLocation, + ]) + + const handleMapCreate = useCallback((createdMap: kakao.maps.Map) => { + setMap(createdMap) + }, []) + + return { + handleKeywordChange, + handleMapCreate, + handleMarkerClick, + handleRecentSearchClick, + handleSubmit, + handleUseTypedLocation, + isSearching, + isTypedLocationSelected, + keyword, + markers, + panelCaption, + panelMessage, + panelPlaces, + panelTitle, + recentSearches, + removeRecentSearch, + selectedPlaceId, + showExpandedResults, + showPreviewResults, + shouldShowRecentSearches, + trimmedKeyword, + selectPlace, + } +} + +export default useSearchPlace diff --git a/src/shared/utils/useSearchPlacePanelModel.ts b/src/shared/utils/useSearchPlacePanelModel.ts new file mode 100644 index 0000000..eeacd14 --- /dev/null +++ b/src/shared/utils/useSearchPlacePanelModel.ts @@ -0,0 +1,132 @@ +import { useMemo } from 'react' + +import type { PlaceResult, ResultsView, SearchState } from '../types/schedule/types' +import { MAX_RECENT_SEARCHES } from './searchPlace' + +type UseSearchPlacePanelModelParams = { + issue: 'missing_key' | 'sdk_loading' | 'search_failed' | null + places: PlaceResult[] + recentSearches: string[] + requestedKeyword: string + resultsView: ResultsView + searchState: SearchState + selectedPlaceId: string | null + trimmedKeyword: string + trimmedSelectedLocation: string +} + +const getSearchMessage = ({ + placesCount, + issue, + requestedKeyword, + resultsView, + searchState, +}: { + issue: 'missing_key' | 'sdk_loading' | 'search_failed' | null + placesCount: number + requestedKeyword: string + resultsView: ResultsView + searchState: SearchState +}) => { + if (searchState === 'loading') { + if (issue === 'sdk_loading') { + return '지도를 불러오는 중입니다. 잠시 후 다시 시도해주세요.' + } + + return `"${requestedKeyword}" 검색 중...` + } + + if (searchState === 'success') { + return resultsView === 'expanded' + ? `${placesCount}개의 장소를 찾았습니다.` + : `${placesCount}개 장소가 검색되었습니다.` + } + + if (searchState === 'zero') return '검색 결과가 없습니다.' + if (issue === 'missing_key') return '카카오맵 API 키가 설정되지 않았습니다.' + if (searchState === 'error') return '장소 검색 중 오류가 발생했습니다. 다시 시도해주세요.' + + return '최근 검색어가 없습니다.' +} + +const useSearchPlacePanelModel = ({ + issue, + places, + recentSearches, + requestedKeyword, + resultsView, + searchState, + selectedPlaceId, + trimmedKeyword, + trimmedSelectedLocation, +}: UseSearchPlacePanelModelParams) => + useMemo(() => { + const inlinePlaces = places.slice(0, 4) + const showPreviewResults = + resultsView === 'inline' && searchState === 'success' && inlinePlaces.length > 0 + const showExpandedResults = + resultsView === 'expanded' && searchState === 'success' && places.length > 0 + const shouldShowRecentSearches = resultsView === 'hidden' && recentSearches.length > 0 + const hasSelectedPlacePreview = + resultsView === 'hidden' && + searchState === 'success' && + selectedPlaceId !== null && + trimmedSelectedLocation.length > 0 + + const panelTitle = showExpandedResults + ? '검색 결과' + : showPreviewResults + ? '자동 검색' + : hasSelectedPlacePreview + ? '선택된 장소' + : shouldShowRecentSearches + ? '최근 검색어' + : trimmedKeyword + ? '검색 상태' + : '장소 찾기' + + const panelCaption = showExpandedResults + ? `${places.length}개` + : showPreviewResults + ? `${inlinePlaces.length}개 미리보기` + : hasSelectedPlacePreview + ? '지도에 표시 중' + : shouldShowRecentSearches + ? `최대 ${MAX_RECENT_SEARCHES}개` + : null + + const panelMessage = + resultsView === 'expanded' || resultsView === 'inline' + ? getSearchMessage({ + issue, + placesCount: places.length, + requestedKeyword, + resultsView, + searchState, + }) + : hasSelectedPlacePreview + ? '현재 선택된 장소를 기준으로 지도를 보여주고 있습니다.' + : '최근 검색어가 없습니다.' + + return { + panelCaption, + panelMessage, + panelPlaces: showExpandedResults ? places : inlinePlaces, + panelTitle, + showExpandedResults, + showPreviewResults, + shouldShowRecentSearches, + } + }, [ + places, + issue, + recentSearches.length, + requestedKeyword, + resultsView, + searchState, + selectedPlaceId, + trimmedKeyword, + trimmedSelectedLocation, + ]) + +export default useSearchPlacePanelModel From d7c8efd88ec384906afa7fe6d904c86abbd6167f Mon Sep 17 00:00:00 2001 From: Yeonjin Kim Date: Mon, 27 Apr 2026 16:15:09 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EA=B3=B5=EC=9C=A0=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=B9=9C=EA=B5=AC=20=EC=B4=88=EB=8C=80=20UI=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/styles/theme.ts | 5 +- src/shared/types/modal/scheduleEditor.ts | 1 + .../EditorModalLayout.style.ts | 13 +- .../ItemEditorModal/EditorModalLayout.tsx | 3 + .../ui/Modals/ItemEditorModal/index.tsx | 11 + .../ScheduleEditor/ScheduleEditorContent.tsx | 2 + .../ScheduleEditor/ScheduleEditorFields.tsx | 5 + .../ScheduleEditor/ShareSchedulePanel.tsx | 82 ++++++ .../ui/Modals/ScheduleEditor/index.style.ts | 73 ++++- .../SearchFriend/SearchFriend.style.ts | 133 +++++++++ .../SearchFriend/SearchFriend.tsx | 253 ++++++++++++++++++ 11 files changed, 575 insertions(+), 6 deletions(-) create mode 100644 src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx create mode 100644 src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.style.ts create mode 100644 src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx diff --git a/src/shared/styles/theme.ts b/src/shared/styles/theme.ts index 053f01a..c4f57d8 100644 --- a/src/shared/styles/theme.ts +++ b/src/shared/styles/theme.ts @@ -8,7 +8,10 @@ export const colors = { textColor3: '#757575', // --Text-Color-3 inputColor: '#F7F7F7', red: '#E94B43', // --Red - + share: { + base: '#ECEBFF', + point: '#594FCA', + }, white: '#FDFDFD', // 메뉴 탭 background: '#FAFAFA', // 전체 바탕 black: '#111827', // 모든 블랙 대체 diff --git a/src/shared/types/modal/scheduleEditor.ts b/src/shared/types/modal/scheduleEditor.ts index 8403bf2..3c4a8a6 100644 --- a/src/shared/types/modal/scheduleEditor.ts +++ b/src/shared/types/modal/scheduleEditor.ts @@ -18,6 +18,7 @@ export type ScheduleEditorFormProps = { initialEvent?: CalendarEvent | null onEventColorChange?: (eventId: CalendarEvent['id'], color: EventColorType) => void onEventTitleConfirm?: (eventId: CalendarEvent['id'], title: string) => void + onSharedChange?: (isShared: boolean) => void onEventTimingChange?: ( eventId: CalendarEvent['id'], start: Date, diff --git a/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.style.ts b/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.style.ts index c52c117..8be7014 100644 --- a/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.style.ts +++ b/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.style.ts @@ -22,7 +22,6 @@ export const ModalOverlay = styled.div` ${media.down(theme.breakPoints.tablet)} { position: fixed; align-items: flex-start; - padding-top: 60px; box-sizing: border-box; gap: 0; } @@ -56,7 +55,6 @@ export const ModalWrapper = styled.div<{ mode: 'modal' | 'inline' }>` inset: 0; ${media.down(theme.breakPoints.desktop)} { position: fixed; - padding-top: 60px; align-self: center; justify-self: center; gap: 0; @@ -105,12 +103,14 @@ export const ModalContent = styled.div` display: flex; flex-direction: column; overflow-y: scroll; + overflow-x: visible; + max-height: 600px; scrollbar-gutter: stable both-edges; ` export const ModalFooter = styled.div` display: flex; justify-content: flex-end; - padding: 0px 24px 24px 24px; + padding: 24px; ` export const Button = styled.button` background-color: ${theme.colors.primary2}; @@ -120,6 +120,11 @@ export const Button = styled.button` display: flex; align-items: center; justify-content: center; + gap: 6px; + color: ${theme.colors.white}; + font-size: 14px; + font-weight: 600; + white-space: nowrap; cursor: pointer; ` @@ -130,6 +135,8 @@ export const FooterRight = styled.div` ` export const FooterLeft = styled.div` flex: 1; + display: flex; + align-items: center; ` export const InlineWrapper = styled.div` diff --git a/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.tsx b/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.tsx index e25f4e4..fdc3282 100644 --- a/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.tsx +++ b/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.tsx @@ -15,6 +15,7 @@ const EditorModalLayout = ({ submitFormId, handleDelete, headerExtras, + submitButtonLabel, mode, headerTitleContainerRef, modalWrapperRef, @@ -28,6 +29,7 @@ const EditorModalLayout = ({ mode: 'modal' | 'inline' handleDelete?: () => void headerExtras?: React.ReactNode + submitButtonLabel?: string headerTitleContainerRef?: Ref modalWrapperRef?: Ref }) => { @@ -64,6 +66,7 @@ const EditorModalLayout = ({ } } > + {submitButtonLabel && {submitButtonLabel}} diff --git a/src/shared/ui/Modals/ItemEditorModal/index.tsx b/src/shared/ui/Modals/ItemEditorModal/index.tsx index 4b96831..5ff240a 100644 --- a/src/shared/ui/Modals/ItemEditorModal/index.tsx +++ b/src/shared/ui/Modals/ItemEditorModal/index.tsx @@ -80,6 +80,7 @@ const ItemEditorModal = ({ [onExternalDraftChange], ) const [footerChildren, setFooterChildren] = useState(null) + const [isScheduleShared, setIsScheduleShared] = useState(false) const [deleteHandler, setDeleteHandler] = useState<() => void>(() => () => undefined) const [closeGuard, setCloseGuard] = useState boolean)>(null) const [modalWrapperElement, setModalWrapperElement] = useState(null) @@ -118,6 +119,12 @@ const ItemEditorModal = ({ setActiveType(initialType) }, [initialType]) + useEffect(() => { + if (activeType !== 'schedule') { + setIsScheduleShared(false) + } + }, [activeType]) + useEffect(() => { if (externalDraftValues !== undefined) return setInternalDraftValues( @@ -247,6 +254,9 @@ const ItemEditorModal = ({ submitFormId={activeType === 'todo' ? 'add-todo-form' : 'add-schedule-form'} handleDelete={deleteHandler} footerChildren={footerChildren} + submitButtonLabel={ + activeType === 'schedule' && isScheduleShared ? '저장 및 초대 전송' : undefined + } headerExtras={showTypeTabs ? tabs : undefined} headerTitleContainerRef={handleHeaderTitleRef} modalWrapperRef={handleModalWrapperRef} @@ -287,6 +297,7 @@ const ItemEditorModal = ({ onEventColorChange={onEventColorChange} onEventTitleConfirm={onEventTitleConfirm} onEventTimingChange={onEventTimingChange} + onSharedChange={setIsScheduleShared} /> )} diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleEditorContent.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleEditorContent.tsx index 0579599..2d8f62a 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleEditorContent.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleEditorContent.tsx @@ -36,6 +36,7 @@ const ScheduleEditorContent = ({ onEventColorChange, onEventTitleConfirm, onEventTimingChange, + onSharedChange, schedule, }: ScheduleEditorContentProps) => { const { @@ -185,6 +186,7 @@ const ScheduleEditorContent = ({ updateConfig={updateConfig} handleRepeatType={handleRepeatType} onTitleConfirm={handleTitleConfirm} + onSharedChange={onSharedChange} /> void handleAllDayToggle: () => void onTitleConfirm: (value: string) => void + onSharedChange?: (isShared: boolean) => void } const ScheduleEditorFields = ({ @@ -26,6 +29,7 @@ const ScheduleEditorFields = ({ handleRepeatType, handleAllDayToggle, onTitleConfirm, + onSharedChange, }: ScheduleEditorFieldsProps) => { return ( <> @@ -39,6 +43,7 @@ const ScheduleEditorFields = ({ + ) } diff --git a/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx b/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx new file mode 100644 index 0000000..8d0452c --- /dev/null +++ b/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react' + +import Arrow from '@/shared/assets/icons/chevron.svg?react' +import Close from '@/shared/assets/icons/close.svg?react' +import SearchFriend, { + type ScheduleShareFriend, +} from '@/shared/ui/scheduleTodo/SearchFriend/SearchFriend' + +import * as S from './index.style' + +type ShareSchedulePanelProps = { + onSharedChange?: (isShared: boolean) => void +} + +const ShareSchedulePanel = ({ onSharedChange }: ShareSchedulePanelProps) => { + const [isOpen, setIsOpen] = useState(false) + const [selectedFriends, setSelectedFriends] = useState([]) + + useEffect(() => { + onSharedChange?.(selectedFriends.length > 0) + }, [onSharedChange, selectedFriends.length]) + + const handleToggleFriend = (friend: ScheduleShareFriend) => { + setSelectedFriends((previous) => { + const isSelected = previous.some((selectedFriend) => selectedFriend.userId === friend.userId) + + if (isSelected) { + return previous.filter((selectedFriend) => selectedFriend.userId !== friend.userId) + } + + return [...previous, friend] + }) + } + + const handleRemoveFriend = (friendId: ScheduleShareFriend['userId']) => { + setSelectedFriends((previous) => previous.filter((friend) => friend.userId !== friendId)) + } + + return ( + + setIsOpen(!isOpen)} + isOpen={isOpen} + isShared={selectedFriends.length > 0} + > +
+
+ 공유 일정 +
+ + + {isOpen && ( + + + +
+ {selectedFriends.map((friend) => ( +
+ {friend.userName} + +
+ ))} +
+
+ )} + + ) +} + +export default ShareSchedulePanel diff --git a/src/shared/ui/Modals/ScheduleEditor/index.style.ts b/src/shared/ui/Modals/ScheduleEditor/index.style.ts index 099837b..4235a4c 100644 --- a/src/shared/ui/Modals/ScheduleEditor/index.style.ts +++ b/src/shared/ui/Modals/ScheduleEditor/index.style.ts @@ -139,8 +139,6 @@ export const CalendarPlaceholder = styled.div` export const Section = styled.div` display: flex; flex-direction: column; - padding: 0 20px; - gap: 20px; margin-top: 24px; ` export const TextareaWrapper = styled.div` @@ -183,3 +181,74 @@ export const BottomSection = styled.div` flex-direction: column; gap: 12px; ` + +export const FriendWrapper = styled.div` + display: flex; + width: 100%; + flex-direction: column; +` +export const FriendSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 30px; + background-color: #f7f7f7; + .added-friend-list { + display: flex; + gap: 6px; + flex-wrap: wrap; + } + .added-friend { + border-radius: 40px; + font-size: 12px; + border: 1px solid #d5d4e3; + background: #fff; + display: flex; + padding: 6px 8px 6px 12px; + justify-content: center; + align-items: center; + gap: 5px; + width: fit-content; + color: #5d5b71; + } + .remove-friend-button { + border: none; + background: transparent; + color: #d5d4e3; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + } +` +export const FriendSectionOpenButton = styled.button<{ isOpen: boolean; isShared: boolean }>` + display: flex; + width: 100%; + justify-content: space-between; + padding: 12px 20px; + cursor: pointer; + align-items: center; + .arrow { + transform: ${({ isOpen }) => (isOpen ? 'rotate(-90deg)' : 'rotate(180deg)')}; + } + .section-title { + display: flex; + gap: 4px; + align-items: center; + border-radius: 40px; + padding: 6px 8px; + background-color: ${({ isShared }) => (isShared ? theme.colors.share.base : '#f7f7f7')}; + border: 1px solid ${({ isShared }) => (isShared ? '#f0f0f0' : '#eaeaea')}; + font-size: 12px; + font-weight: 600; + color: ${({ isShared }) => (isShared ? theme.colors.share.point : theme.colors.textColor3)}; + } + .dot { + width: 5px; + height: 5px; + border-radius: 50%; + background-color: ${({ isShared }) => + isShared ? theme.colors.share.point : theme.colors.textColor3}; + } +` diff --git a/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.style.ts b/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.style.ts new file mode 100644 index 0000000..9e18cff --- /dev/null +++ b/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.style.ts @@ -0,0 +1,133 @@ +import styled from '@emotion/styled' + +import { theme } from '@/shared/styles' +export const InputWrapper = styled.div` + position: relative; + background-color: ${theme.colors.share.base}; + padding: 12px; + border-radius: 20px; +` + +export const SearchInput = styled.input` + width: 100%; + border-radius: 12px; + padding: 12px 84px 12px 14px; + border: 1px solid #acacac; + background-color: ${theme.colors.white}; + color: ${theme.colors.textColor2}; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; + + &::placeholder { + color: #9a9a9a; + } + + &:focus { + outline: none; + border-color: ${theme.colors.primary2}; + box-shadow: 0 0 0 4px rgba(19, 138, 172, 0.08); + } +` + +export const InputActions = styled.div` + position: absolute; + top: 34px; + right: 20px; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 6px; +` + +const InputActionButton = styled.button` + width: 32px; + height: 32px; + border: none; + border-radius: 10px; + display: grid; + place-items: center; + color: ${theme.colors.primary2}; + cursor: pointer; + transition: + background-color 0.2s ease, + color 0.2s ease; + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + &:not(:disabled):hover { + background-color: ${theme.colors.primary2}; + color: ${theme.colors.white}; + } +` + +export const SearchButton = styled(InputActionButton)`` + +export const SearchResult = styled.div<{ position: { left: number; top: number; width: number } }>` + position: fixed; + left: ${({ position }) => position.left}px; + top: ${({ position }) => position.top}px; + width: ${({ position }) => position.width}px; + z-index: 22000; + background-color: ${theme.colors.white}; + border-radius: 20px; + padding: 12px; + gap: 4px; + display: flex; + flex-direction: column; + max-height: 158px; + overflow-y: auto; + overflow-anchor: none; +` + +export const SearchResultItem = styled.button<{ isAdded: boolean }>` + width: 100%; + border: none; + padding: 7px 24px; + border-radius: 16px; + align-items: center; + cursor: pointer; + display: flex; + font-size: 14px; + font-weight: 500; + transition: + background-color 0.2s ease, + color 0.2s ease; + background-color: ${(props) => (props.isAdded ? theme.colors.share.base : '#f1f1f1')}; + + .divider { + width: 1px; + height: 16px; + background-color: #d2d3d2; + margin: 0 8px; + } + .plus { + margin-left: auto; + } + .close { + margin-left: auto; + } +` + +export const EmptySearchResult = styled.div` + width: 100%; + padding: 14px 16px; + border-radius: 16px; + background-color: #f1f1f1; + color: ${theme.colors.textColor3}; + font-size: 14px; + font-weight: 500; + text-align: center; +` + +export const Name = styled.div` + color: #655446; +` + +export const Email = styled.div` + color: ${theme.colors.textColor2}; + font-weight: 400; +` diff --git a/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx b/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx new file mode 100644 index 0000000..ab42235 --- /dev/null +++ b/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx @@ -0,0 +1,253 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +import Close from '@/shared/assets/icons/close.svg?react' +import Plus from '@/shared/assets/icons/plus.svg?react' +import Search from '@/shared/assets/icons/search.svg?react' +import { theme } from '@/shared/styles' + +import * as S from './SearchFriend.style' + +export type ScheduleShareFriend = { + userName: string + userId: string + email: string +} + +type SearchFriendProps = { + selectedFriends: ScheduleShareFriend[] + onToggleFriend: (friend: ScheduleShareFriend) => void +} + +const MOCK_FRIENDS: ScheduleShareFriend[] = [ + { userName: '김철수', userId: '1', email: 'asdf@asdf.com' }, + { userName: '김철수', userId: '5', email: 'asdf@asdf.com' }, + { userName: '김철수', userId: '6', email: 'asdf@asdf.com' }, + { userName: '김철수', userId: '7', email: 'asdf@asdf.com' }, + { userName: '이영희', userId: '2', email: 'asdf@asdf.com' }, + { userName: '박민수', userId: '3', email: 'asdf@asdf.com' }, + { userName: '최지우', userId: '4', email: 'asdf@asdf.com' }, +] // TODO: 친구 목록 API 연동 필요 + +const OVERFLOW_SCROLL_PATTERN = /(auto|scroll|hidden|clip)/ + +const getIntersectionRect = (firstRect: DOMRect, secondRect: DOMRect) => { + const top = Math.max(firstRect.top, secondRect.top) + const right = Math.min(firstRect.right, secondRect.right) + const bottom = Math.min(firstRect.bottom, secondRect.bottom) + const left = Math.max(firstRect.left, secondRect.left) + + return { + top, + right, + bottom, + left, + width: right - left, + height: bottom - top, + } +} + +const hasVisibleArea = (rect: Pick) => + rect.width > 0 && rect.height > 0 + +const isClippedByScrollableParent = (element: HTMLElement, parent: HTMLElement) => { + const parentStyle = window.getComputedStyle(parent) + const clipsContent = OVERFLOW_SCROLL_PATTERN.test( + `${parentStyle.overflow}${parentStyle.overflowX}${parentStyle.overflowY}`, + ) + + if (!clipsContent) return false + + const visibleRect = getIntersectionRect( + element.getBoundingClientRect(), + parent.getBoundingClientRect(), + ) + + return !hasVisibleArea(visibleRect) +} + +const isElementVisible = (element: HTMLElement) => { + const elementRect = element.getBoundingClientRect() + const viewportRect = new DOMRect(0, 0, window.innerWidth, window.innerHeight) + const viewportVisibleRect = getIntersectionRect(elementRect, viewportRect) + + if (!hasVisibleArea(viewportVisibleRect)) return false + + let parentElement = element.parentElement + + while (parentElement && parentElement !== document.body) { + if (isClippedByScrollableParent(element, parentElement)) return false + parentElement = parentElement.parentElement + } + + return true +} + +const SearchFriend = ({ selectedFriends, onToggleFriend }: SearchFriendProps) => { + const [keyword, setKeyword] = useState('') + const [isOpen, setIsOpen] = useState(false) + const [resultPosition, setResultPosition] = useState({ left: 0, top: 0, width: 0 }) + const inputWrapperRef = useRef(null) + const resultRef = useRef(null) + const selectedFriendIds = useMemo( + () => new Set(selectedFriends.map((friend) => friend.userId)), + [selectedFriends], + ) + const trimmedKeyword = keyword.trim().toLowerCase() + const filteredFriends = useMemo( + () => + trimmedKeyword + ? MOCK_FRIENDS.filter( + (friend) => + friend.userName.toLowerCase().includes(trimmedKeyword) || + friend.email.toLowerCase().includes(trimmedKeyword), + ) + : [], + [trimmedKeyword], + ) + + const shouldShowResult = isOpen && Boolean(trimmedKeyword) + + const updateResultPosition = () => { + const inputWrapper = inputWrapperRef.current + if (!inputWrapper) return + + if (!isElementVisible(inputWrapper)) { + setIsOpen(false) + return + } + + const rect = inputWrapper.getBoundingClientRect() + + setResultPosition({ + left: rect.left + 12, + top: rect.bottom, + width: rect.width - 24, + }) + } + + const renderFriendResult = () => { + if (!shouldShowResult || typeof document === 'undefined') return null + + return createPortal( + + {filteredFriends.length === 0 && ( + 검색 결과가 없습니다 + )} + {filteredFriends.map((friend) => { + const isSelected = selectedFriendIds.has(friend.userId) + + return ( + onToggleFriend(friend)} + > + {friend.userName} +
+ {friend.email} + {isSelected ? ( +