From 255bc6eee3b2004b1a7810fc07aca0483c5e6a7a Mon Sep 17 00:00:00 2001 From: jiji-hoon96 Date: Sat, 9 May 2026 19:16:41 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(admin):=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=20=EC=97=B0=EA=B2=B0=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlaceSearchInput.styles.ts | 137 ++++++++++++ .../src/components/PlaceSearchInput/index.tsx | 197 ++++++++++++++++++ .../ShowBasicInfoFormContent.tsx | 181 +++------------- apps/admin/src/hooks/useKakaoLocalSearch.ts | 178 ++++++++++++++++ 4 files changed, 540 insertions(+), 153 deletions(-) create mode 100644 apps/admin/src/components/PlaceSearchInput/PlaceSearchInput.styles.ts create mode 100644 apps/admin/src/components/PlaceSearchInput/index.tsx create mode 100644 apps/admin/src/hooks/useKakaoLocalSearch.ts diff --git a/apps/admin/src/components/PlaceSearchInput/PlaceSearchInput.styles.ts b/apps/admin/src/components/PlaceSearchInput/PlaceSearchInput.styles.ts new file mode 100644 index 00000000..2ae589d8 --- /dev/null +++ b/apps/admin/src/components/PlaceSearchInput/PlaceSearchInput.styles.ts @@ -0,0 +1,137 @@ +import { mq_lg } from '@boolti/ui'; +import styled from '@emotion/styled'; + +const Container = styled.div` + position: relative; + width: 100%; +`; + +const InputWrapper = styled.div` + position: relative; + display: flex; + align-items: center; +`; + +const SearchInput = styled.input<{ hasError?: boolean }>` + width: 100%; + border-radius: 4px; + padding: 12px 40px 12px 13px; + color: ${({ theme }) => theme.palette.grey.g90}; + border: 1px solid + ${({ hasError, theme }) => + hasError ? `${theme.palette.status.error1} !important` : theme.palette.grey.g20}; + background: ${({ theme }) => theme.palette.grey.w}; + ${({ theme }) => theme.typo.b3}; + + &::placeholder { + color: ${({ theme }) => theme.palette.grey.g30}; + } + + &:focus { + border: 1px solid ${({ theme }) => theme.palette.grey.g90}; + } + + &:disabled { + background: ${({ theme }) => theme.palette.grey.g10}; + border: 1px solid ${({ theme }) => theme.palette.grey.g20}; + color: ${({ theme }) => theme.palette.grey.g40}; + } +`; + +const SearchIconWrapper = styled.div` + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + pointer-events: none; + + svg { + width: 20px; + height: 20px; + } + + path { + stroke: ${({ theme }) => theme.palette.grey.g40}; + } +`; + +const Dropdown = styled.ul` + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 10; + background: ${({ theme }) => theme.palette.grey.w}; + border: 1px solid ${({ theme }) => theme.palette.grey.g20}; + border-radius: 4px; + box-shadow: 0px 8px 14px 0px ${({ theme }) => theme.palette.shadow}; + max-height: 300px; + overflow-y: auto; + list-style: none; + padding: 0; + margin: 0; + + ${mq_lg} { + max-height: 274px; + } +`; + +const DropdownItem = styled.li` + padding: 12px 16px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; + border-bottom: 1px solid ${({ theme }) => theme.palette.grey.g10}; + + &:last-of-type { + border-bottom: none; + } + + &:hover { + background: ${({ theme }) => theme.palette.grey.g00}; + } +`; + +const PlaceName = styled.span` + ${({ theme }) => theme.typo.b3}; + color: ${({ theme }) => theme.palette.grey.g90}; +`; + +const AddressName = styled.span` + ${({ theme }) => theme.typo.b1}; + color: ${({ theme }) => theme.palette.grey.g50}; +`; + +const SelectedInfo = styled.div` + margin-top: 8px; + display: flex; + align-items: center; + gap: 8px; + + div { + width: auto; + flex: 1; + } +`; + +const ErrorMessage = styled.span` + margin-top: 4px; + ${({ theme }) => theme.typo.b1}; + color: ${({ theme }) => theme.palette.status.error1}; +`; + +export default { + Container, + InputWrapper, + SearchInput, + SearchIconWrapper, + Dropdown, + DropdownItem, + PlaceName, + AddressName, + SelectedInfo, + ErrorMessage, +}; diff --git a/apps/admin/src/components/PlaceSearchInput/index.tsx b/apps/admin/src/components/PlaceSearchInput/index.tsx new file mode 100644 index 00000000..0237f5be --- /dev/null +++ b/apps/admin/src/components/PlaceSearchInput/index.tsx @@ -0,0 +1,197 @@ +import { SearchIcon } from '@boolti/icon'; +import { TextField } from '@boolti/ui'; +import { useEffect, useRef, useState } from 'react'; + +import useKakaoLocalSearch, { + PlaceSearchResult, + PlaceSearchResultType, +} from '~/hooks/useKakaoLocalSearch'; + +import Styled from './PlaceSearchInput.styles'; + +interface PlaceSearchInputProps { + initialPlaceName?: string; + initialAddress?: string; + initialDetailAddress?: string; + disabled?: boolean; + errorMessage?: string; + onSelect: (result: { + type: PlaceSearchResultType; + placeName: string; + streetAddress: string; + detailAddress: string; + latitude: number; + longitude: number; + }) => void; + onDetailAddressChange?: (value: string) => void; +} + +const PlaceSearchInput = ({ + initialPlaceName, + initialAddress, + initialDetailAddress, + disabled, + errorMessage, + onSelect, + onDetailAddressChange, +}: PlaceSearchInputProps) => { + const { query, setQuery, results, clearResults } = useKakaoLocalSearch(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [selectedResult, setSelectedResult] = useState(null); + const [detailAddress, setDetailAddress] = useState(initialDetailAddress ?? ''); + const containerRef = useRef(null); + const detailAddressInputRef = useRef(null); + + useEffect(() => { + if (initialPlaceName && initialAddress && !selectedResult) { + setSelectedResult({ + type: 'place', + id: 'initial', + placeName: initialPlaceName, + addressName: initialAddress, + roadAddressName: initialAddress, + x: '0', + y: '0', + }); + } + }, [initialPlaceName, initialAddress, selectedResult]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setQuery(value); + setIsDropdownOpen(true); + + if (selectedResult) { + setSelectedResult(null); + setDetailAddress(''); + } + }; + + const handleSelect = (result: PlaceSearchResult) => { + setSelectedResult(result); + setIsDropdownOpen(false); + clearResults(); + + if (result.type === 'place') { + const address = result.roadAddressName || result.addressName; + setQuery(result.placeName); + setDetailAddress(address); + + onSelect({ + type: 'place', + placeName: result.placeName, + streetAddress: address, + detailAddress: address, + latitude: Number(result.y), + longitude: Number(result.x), + }); + } else { + const address = result.roadAddressName || result.addressName; + setQuery(address); + setDetailAddress(''); + + onSelect({ + type: 'address', + placeName: '', + streetAddress: address, + detailAddress: '', + latitude: Number(result.y), + longitude: Number(result.x), + }); + + setTimeout(() => { + detailAddressInputRef.current?.focus(); + }, 0); + } + }; + + const handleDetailAddressChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setDetailAddress(value); + onDetailAddressChange?.(value); + }; + + const handleInputFocus = () => { + if (results.length > 0 && !selectedResult) { + setIsDropdownOpen(true); + } + }; + + return ( +
+ + + + + + + + + {isDropdownOpen && results.length > 0 && ( + + {results.map((result) => ( + handleSelect(result)}> + + {result.type === 'place' ? result.placeName : result.roadAddressName || result.addressName} + + {result.type === 'place' && ( + + {result.roadAddressName || result.addressName} + + )} + + ))} + + )} + + + {selectedResult && ( + + {selectedResult.type === 'place' ? ( + + ) : ( + + )} + + )} + + {errorMessage && !selectedResult && ( + {errorMessage} + )} +
+ ); +}; + +export default PlaceSearchInput; diff --git a/apps/admin/src/components/ShowInfoFormContent/ShowBasicInfoFormContent.tsx b/apps/admin/src/components/ShowInfoFormContent/ShowBasicInfoFormContent.tsx index 0782afc3..72048322 100644 --- a/apps/admin/src/components/ShowInfoFormContent/ShowBasicInfoFormContent.tsx +++ b/apps/admin/src/components/ShowInfoFormContent/ShowBasicInfoFormContent.tsx @@ -1,16 +1,13 @@ -import { ImageFile, fetcher } from '@boolti/api'; +import { ImageFile } from '@boolti/api'; import { CloseIcon, FileUpIcon } from '@boolti/icon'; -import { Button, TextField, TimePicker, useDialog } from '@boolti/ui'; +import { TextField, TimePicker } from '@boolti/ui'; import { add, format } from 'date-fns'; -import { useRef } from 'react'; import { useDropzone } from 'react-dropzone'; import { Controller, UseFormReturn } from 'react-hook-form'; -import { useNavermaps } from 'react-naver-maps'; -import DaumPostcode from 'react-daum-postcode'; +import PlaceSearchInput from '~/components/PlaceSearchInput'; import Styled from './ShowInfoFormContent.styles'; import { ShowBasicInfoFormInputs } from './types'; -import { useBodyScrollLock } from '~/hooks/useBodyScrollLock'; const MAX_IMAGE_COUNT = 3; const MIN_DATE = format(add(new Date(), { days: 1 }), 'yyyy-MM-dd'); @@ -32,13 +29,10 @@ const ShowBasicInfoFormContent = ({ onDropImage, onDeleteImage, }: ShowBasicInfoFormContentProps) => { - const { open, close, isOpen } = useDialog(); - const naverMaps = useNavermaps(); - const detailAddressInputRef = useRef(null); - const { control, setValue, + watch, formState: { errors }, setError, clearErrors, @@ -53,55 +47,6 @@ const ShowBasicInfoFormContent = ({ onDrop: onDropImage, }); - const openDaumPostCodeWithDialog: React.MouseEventHandler = (e) => { - e.preventDefault(); - open({ - title: '주소 찾기', - content: ( - { - try { - const response = await fetcher.get( - 'web/v1/naver-maps/geocoding', - { - searchParams: { - query: address.address, - filter: `BCODE@${address.bcode};`, - }, - }, - ); - - if ( - response.status === naverMaps.Service.GeocodeStatus.OK && - response.meta.totalCount > 0 - ) { - const foundAddress = response.addresses.find(Boolean); - - if (foundAddress) { - setValue('latitude', Number(foundAddress.y)); - setValue('longitude', Number(foundAddress.x)); - } - } - } catch (e) { - console.warn('[ShowBasicInfoFormContent] geocoding failed:', e); - } - - setValue('placeStreetAddress', address.roadAddress); - - detailAddressInputRef.current?.focus(); - }} - onClose={() => { - close(); - }} - /> - ), - onClose: close, - }); - }; - - useBodyScrollLock(isOpen); - return ( @@ -317,103 +262,33 @@ const ShowBasicInfoFormContent = ({ - 공연장명 - - ( - { - onChange(event); - clearErrors('placeName'); - }} - onBlur={() => { - onBlur(); - - if (!value) { - setError('placeName', { type: 'required', message: '필수 입력사항입니다.' }); - return; - } - }} - value={value ?? ''} - errorMessage={errors.placeName?.message} - /> - )} - name="placeName" - /> - - - - - - 공연장 주소 + 공연장 - { + setValue('placeName', result.placeName || result.streetAddress); + setValue('placeStreetAddress', result.streetAddress); + setValue('placeDetailAddress', result.detailAddress); + setValue('latitude', result.latitude); + setValue('longitude', result.longitude); + clearErrors('placeName'); + clearErrors('placeStreetAddress'); + clearErrors('placeDetailAddress'); }} - render={({ field: { value } }) => ( - <> - - - - )} - name="placeStreetAddress" - /> - - - { + setValue('placeDetailAddress', value); + if (value) { + clearErrors('placeDetailAddress'); + } }} - render={({ field: { onChange, onBlur, value } }) => ( - { - onChange(event); - clearErrors('placeDetailAddress'); - }} - onBlur={() => { - onBlur(); - - if (!value) { - setError('placeDetailAddress', { - type: 'required', - message: '필수 입력사항입니다.', - }); - return; - } - }} - value={value ?? ''} - errorMessage={errors.placeDetailAddress?.message} - /> - )} - name="placeDetailAddress" /> diff --git a/apps/admin/src/hooks/useKakaoLocalSearch.ts b/apps/admin/src/hooks/useKakaoLocalSearch.ts new file mode 100644 index 00000000..24c31c22 --- /dev/null +++ b/apps/admin/src/hooks/useKakaoLocalSearch.ts @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +const KAKAO_REST_API_KEY = import.meta.env.VITE_KAKAO_REST_API_KEY; +const KAKAO_LOCAL_KEYWORD_URL = 'https://dapi.kakao.com/v2/local/search/keyword.json'; +const KAKAO_LOCAL_ADDRESS_URL = 'https://dapi.kakao.com/v2/local/search/address.json'; + +const DEBOUNCE_MS = 300; + +export interface KakaoKeywordResult { + id: string; + place_name: string; + category_group_code: string; + category_group_name: string; + address_name: string; + road_address_name: string; + x: string; + y: string; + phone: string; +} + +export interface KakaoAddressResult { + address_name: string; + address_type: 'REGION' | 'ROAD' | 'REGION_ADDR' | 'ROAD_ADDR'; + x: string; + y: string; + address: { + address_name: string; + region_1depth_name: string; + region_2depth_name: string; + region_3depth_name: string; + } | null; + road_address: { + address_name: string; + road_name: string; + building_name: string; + } | null; +} + +export type PlaceSearchResultType = 'place' | 'address'; + +export interface PlaceSearchResult { + type: PlaceSearchResultType; + id: string; + placeName: string; + addressName: string; + roadAddressName: string; + x: string; + y: string; +} + +const searchKeyword = async (query: string): Promise => { + const url = new URL(KAKAO_LOCAL_KEYWORD_URL); + url.searchParams.set('query', query); + url.searchParams.set('size', '5'); + + const response = await fetch(url.toString(), { + headers: { Authorization: `KakaoAK ${KAKAO_REST_API_KEY}` }, + }); + + if (!response.ok) return []; + + const data = await response.json(); + return (data.documents as KakaoKeywordResult[]).map((doc) => ({ + type: 'place' as const, + id: doc.id, + placeName: doc.place_name, + addressName: doc.address_name, + roadAddressName: doc.road_address_name, + x: doc.x, + y: doc.y, + })); +}; + +const searchAddress = async (query: string): Promise => { + const url = new URL(KAKAO_LOCAL_ADDRESS_URL); + url.searchParams.set('query', query); + url.searchParams.set('size', '5'); + + const response = await fetch(url.toString(), { + headers: { Authorization: `KakaoAK ${KAKAO_REST_API_KEY}` }, + }); + + if (!response.ok) return []; + + const data = await response.json(); + return (data.documents as KakaoAddressResult[]).map((doc, index) => ({ + type: 'address' as const, + id: `addr-${index}-${doc.x}-${doc.y}`, + placeName: '', + addressName: doc.address?.address_name ?? doc.address_name, + roadAddressName: doc.road_address?.address_name ?? doc.address_name, + x: doc.x, + y: doc.y, + })); +}; + +const useKakaoLocalSearch = () => { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const timerRef = useRef | null>(null); + const abortRef = useRef(null); + + const search = useCallback(async (keyword: string) => { + if (!keyword.trim()) { + setResults([]); + return; + } + + setIsLoading(true); + + if (abortRef.current) { + abortRef.current.abort(); + } + abortRef.current = new AbortController(); + + try { + const [keywordResults, addressResults] = await Promise.all([ + searchKeyword(keyword), + searchAddress(keyword), + ]); + + const seenIds = new Set(); + const merged: PlaceSearchResult[] = []; + + for (const result of keywordResults) { + if (!seenIds.has(result.id)) { + seenIds.add(result.id); + merged.push(result); + } + } + + for (const result of addressResults) { + if (!seenIds.has(result.id)) { + seenIds.add(result.id); + merged.push(result); + } + } + + setResults(merged.slice(0, 8)); + } catch { + setResults([]); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + search(query); + }, DEBOUNCE_MS); + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, [query, search]); + + const clearResults = useCallback(() => { + setResults([]); + setQuery(''); + }, []); + + return { + query, + setQuery, + results, + isLoading, + clearResults, + }; +}; + +export default useKakaoLocalSearch; From f5204332b89c4ef13f75e30c27a5b1e34f41c374 Mon Sep 17 00:00:00 2001 From: jiji-hoon96 Date: Sun, 10 May 2026 22:03:26 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(admin):=20react=20daum=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pnp.cjs | 23 ----------------------- apps/admin/package.json | 1 - yarn.lock | 10 ---------- 3 files changed, 34 deletions(-) diff --git a/.pnp.cjs b/.pnp.cjs index 46af3271..51a533b1 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -9755,7 +9755,6 @@ const RAW_RUNTIME_STATE = ["qrcode.react", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:3.1.0"],\ ["quill", "npm:2.0.3"],\ ["react", "npm:18.2.0"],\ - ["react-daum-postcode", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:3.1.3"],\ ["react-dom", "virtual:de80dc576383b2386358abc0e9fe49c00e3397fe355a0337462b73ab3115c2e557eb85784ee0fe776394cc11dd020b4e84dbbd75acf72ee6d54415d82d21f5c5#npm:18.2.0"],\ ["react-dropzone", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:14.2.3"],\ ["react-error-boundary", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:4.1.2"],\ @@ -18937,28 +18936,6 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ - ["react-daum-postcode", [\ - ["npm:3.1.3", {\ - "packageLocation": "./.yarn/cache/react-daum-postcode-npm-3.1.3-95dbfacabd-72f05078e2.zip/node_modules/react-daum-postcode/",\ - "packageDependencies": [\ - ["react-daum-postcode", "npm:3.1.3"]\ - ],\ - "linkType": "SOFT"\ - }],\ - ["virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:3.1.3", {\ - "packageLocation": "./.yarn/__virtual__/react-daum-postcode-virtual-a70527b50a/0/cache/react-daum-postcode-npm-3.1.3-95dbfacabd-72f05078e2.zip/node_modules/react-daum-postcode/",\ - "packageDependencies": [\ - ["react-daum-postcode", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:3.1.3"],\ - ["@types/react", "npm:18.2.48"],\ - ["react", "npm:18.2.0"]\ - ],\ - "packagePeers": [\ - "@types/react",\ - "react"\ - ],\ - "linkType": "HARD"\ - }]\ - ]],\ ["react-dnd-html5-backend", [\ ["npm:16.0.1", {\ "packageLocation": "./.yarn/cache/react-dnd-html5-backend-npm-16.0.1-754940d855-6e4b632a11.zip/node_modules/react-dnd-html5-backend/",\ diff --git a/apps/admin/package.json b/apps/admin/package.json index b0ce479e..ffcaf83f 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -39,7 +39,6 @@ "qrcode.react": "^3.1.0", "quill": "2.0.3", "react": "^18.2.0", - "react-daum-postcode": "^3.1.3", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-error-boundary": "^4.1.2", diff --git a/yarn.lock b/yarn.lock index b56913fb..f250e87e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6350,7 +6350,6 @@ __metadata: qrcode.react: "npm:^3.1.0" quill: "npm:2.0.3" react: "npm:^18.2.0" - react-daum-postcode: "npm:^3.1.3" react-dom: "npm:^18.2.0" react-dropzone: "npm:^14.2.3" react-error-boundary: "npm:^4.1.2" @@ -13978,15 +13977,6 @@ __metadata: languageName: node linkType: hard -"react-daum-postcode@npm:^3.1.3": - version: 3.1.3 - resolution: "react-daum-postcode@npm:3.1.3" - peerDependencies: - react: ">=16.8.0" - checksum: 10c0/72f05078e25df34d9196d573105c799bb85c7fd36f397af1f9edeec9b2aa1babd8ce90163f79b38fdcc7194d1b73b5df999fd3921fa8cc498fa7a05be755e955 - languageName: node - linkType: hard - "react-dnd-html5-backend@npm:^16.0.1": version: 16.0.1 resolution: "react-dnd-html5-backend@npm:16.0.1" From b82e9febef9c78e992a6ffd6a7147a1218d15c67 Mon Sep 17 00:00:00 2001 From: jiji-hoon96 Date: Mon, 18 May 2026 23:40:51 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(admin):=20=EA=B3=B5=EC=97=B0=EC=9E=A5?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20UX=20=EB=B0=8F=20=ED=8F=BC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 드롭다운 place 항목에 공간분류(category) 노출 - place 선택 시 detailAddress 중복 저장 버그 수정 (빈 값으로 전송) - 공연장 관련 필드에 required 검증 추가 및 선택 해제 시 에러 처리 - 검색 중 / 결과 없음 상태 UI 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PlaceSearchInput.styles.ts | 21 ++++++ .../src/components/PlaceSearchInput/index.tsx | 68 ++++++++++++------- .../ShowBasicInfoFormContent.tsx | 41 ++++++++--- apps/admin/src/hooks/useKakaoLocalSearch.ts | 3 + 4 files changed, 99 insertions(+), 34 deletions(-) diff --git a/apps/admin/src/components/PlaceSearchInput/PlaceSearchInput.styles.ts b/apps/admin/src/components/PlaceSearchInput/PlaceSearchInput.styles.ts index 2ae589d8..9cdb2be6 100644 --- a/apps/admin/src/components/PlaceSearchInput/PlaceSearchInput.styles.ts +++ b/apps/admin/src/components/PlaceSearchInput/PlaceSearchInput.styles.ts @@ -95,11 +95,22 @@ const DropdownItem = styled.li` } `; +const PlaceNameRow = styled.div` + display: flex; + align-items: baseline; + gap: 6px; +`; + const PlaceName = styled.span` ${({ theme }) => theme.typo.b3}; color: ${({ theme }) => theme.palette.grey.g90}; `; +const Category = styled.span` + ${({ theme }) => theme.typo.b1}; + color: ${({ theme }) => theme.palette.grey.g40}; +`; + const AddressName = styled.span` ${({ theme }) => theme.typo.b1}; color: ${({ theme }) => theme.palette.grey.g50}; @@ -123,6 +134,13 @@ const ErrorMessage = styled.span` color: ${({ theme }) => theme.palette.status.error1}; `; +const EmptyState = styled.div` + padding: 16px; + text-align: center; + ${({ theme }) => theme.typo.b1}; + color: ${({ theme }) => theme.palette.grey.g50}; +`; + export default { Container, InputWrapper, @@ -130,8 +148,11 @@ export default { SearchIconWrapper, Dropdown, DropdownItem, + PlaceNameRow, PlaceName, + Category, AddressName, SelectedInfo, ErrorMessage, + EmptyState, }; diff --git a/apps/admin/src/components/PlaceSearchInput/index.tsx b/apps/admin/src/components/PlaceSearchInput/index.tsx index 0237f5be..63e1cdad 100644 --- a/apps/admin/src/components/PlaceSearchInput/index.tsx +++ b/apps/admin/src/components/PlaceSearchInput/index.tsx @@ -23,6 +23,7 @@ interface PlaceSearchInputProps { latitude: number; longitude: number; }) => void; + onClear?: () => void; onDetailAddressChange?: (value: string) => void; } @@ -33,21 +34,25 @@ const PlaceSearchInput = ({ disabled, errorMessage, onSelect, + onClear, onDetailAddressChange, }: PlaceSearchInputProps) => { - const { query, setQuery, results, clearResults } = useKakaoLocalSearch(); + const { query, setQuery, results, isLoading, clearResults } = useKakaoLocalSearch(); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [selectedResult, setSelectedResult] = useState(null); + const [streetAddress, setStreetAddress] = useState(initialAddress ?? ''); const [detailAddress, setDetailAddress] = useState(initialDetailAddress ?? ''); const containerRef = useRef(null); const detailAddressInputRef = useRef(null); useEffect(() => { - if (initialPlaceName && initialAddress && !selectedResult) { + if (!selectedResult && initialAddress) { + const isPlace = Boolean(initialPlaceName); setSelectedResult({ - type: 'place', + type: isPlace ? 'place' : 'address', id: 'initial', - placeName: initialPlaceName, + placeName: initialPlaceName ?? '', + category: '', addressName: initialAddress, roadAddressName: initialAddress, x: '0', @@ -74,7 +79,9 @@ const PlaceSearchInput = ({ if (selectedResult) { setSelectedResult(null); + setStreetAddress(''); setDetailAddress(''); + onClear?.(); } }; @@ -83,23 +90,23 @@ const PlaceSearchInput = ({ setIsDropdownOpen(false); clearResults(); + const address = result.roadAddressName || result.addressName; + setStreetAddress(address); + setDetailAddress(''); + if (result.type === 'place') { - const address = result.roadAddressName || result.addressName; setQuery(result.placeName); - setDetailAddress(address); onSelect({ type: 'place', placeName: result.placeName, streetAddress: address, - detailAddress: address, + detailAddress: '', latitude: Number(result.y), longitude: Number(result.x), }); } else { - const address = result.roadAddressName || result.addressName; setQuery(address); - setDetailAddress(''); onSelect({ type: 'address', @@ -123,7 +130,7 @@ const PlaceSearchInput = ({ }; const handleInputFocus = () => { - if (results.length > 0 && !selectedResult) { + if (!selectedResult && query.trim()) { setIsDropdownOpen(true); } }; @@ -145,20 +152,33 @@ const PlaceSearchInput = ({ - {isDropdownOpen && results.length > 0 && ( + {isDropdownOpen && query.trim() && !selectedResult && ( - {results.map((result) => ( - handleSelect(result)}> - - {result.type === 'place' ? result.placeName : result.roadAddressName || result.addressName} - - {result.type === 'place' && ( - - {result.roadAddressName || result.addressName} - - )} - - ))} + {isLoading && results.length === 0 ? ( + 검색 중... + ) : results.length === 0 ? ( + 검색 결과가 없어요 + ) : ( + results.map((result) => ( + handleSelect(result)}> + + + {result.type === 'place' + ? result.placeName + : result.roadAddressName || result.addressName} + + {result.type === 'place' && result.category && ( + {result.category} + )} + + {result.type === 'place' && ( + + {result.roadAddressName || result.addressName} + + )} + + )) + )} )} @@ -169,7 +189,7 @@ const PlaceSearchInput = ({ diff --git a/apps/admin/src/components/ShowInfoFormContent/ShowBasicInfoFormContent.tsx b/apps/admin/src/components/ShowInfoFormContent/ShowBasicInfoFormContent.tsx index 72048322..ed0f7476 100644 --- a/apps/admin/src/components/ShowInfoFormContent/ShowBasicInfoFormContent.tsx +++ b/apps/admin/src/components/ShowInfoFormContent/ShowBasicInfoFormContent.tsx @@ -2,6 +2,7 @@ import { ImageFile } from '@boolti/api'; import { CloseIcon, FileUpIcon } from '@boolti/icon'; import { TextField, TimePicker } from '@boolti/ui'; import { add, format } from 'date-fns'; +import { useEffect } from 'react'; import { useDropzone } from 'react-dropzone'; import { Controller, UseFormReturn } from 'react-hook-form'; @@ -9,6 +10,8 @@ import PlaceSearchInput from '~/components/PlaceSearchInput'; import Styled from './ShowInfoFormContent.styles'; import { ShowBasicInfoFormInputs } from './types'; +const VENUE_REQUIRED_MESSAGE = '공연장을 선택해 주세요'; + const MAX_IMAGE_COUNT = 3; const MIN_DATE = format(add(new Date(), { days: 1 }), 'yyyy-MM-dd'); @@ -31,6 +34,7 @@ const ShowBasicInfoFormContent = ({ }: ShowBasicInfoFormContentProps) => { const { control, + register, setValue, watch, formState: { errors }, @@ -38,6 +42,13 @@ const ShowBasicInfoFormContent = ({ clearErrors, } = form; + useEffect(() => { + register('placeName', { required: VENUE_REQUIRED_MESSAGE }); + register('placeStreetAddress', { required: VENUE_REQUIRED_MESSAGE }); + register('latitude', { required: VENUE_REQUIRED_MESSAGE }); + register('longitude', { required: VENUE_REQUIRED_MESSAGE }); + }, [register]); + const { getRootProps, getInputProps } = useDropzone({ accept: { 'image/jpeg': [], @@ -274,20 +285,30 @@ const ShowBasicInfoFormContent = ({ errors.placeStreetAddress?.message } onSelect={(result) => { - setValue('placeName', result.placeName || result.streetAddress); - setValue('placeStreetAddress', result.streetAddress); + setValue('placeName', result.placeName || result.streetAddress, { + shouldValidate: true, + }); + setValue('placeStreetAddress', result.streetAddress, { + shouldValidate: true, + }); setValue('placeDetailAddress', result.detailAddress); - setValue('latitude', result.latitude); - setValue('longitude', result.longitude); - clearErrors('placeName'); - clearErrors('placeStreetAddress'); - clearErrors('placeDetailAddress'); + setValue('latitude', result.latitude, { shouldValidate: true }); + setValue('longitude', result.longitude, { shouldValidate: true }); + clearErrors(['placeName', 'placeStreetAddress', 'placeDetailAddress']); + }} + onClear={() => { + setValue('placeName', ''); + setValue('placeStreetAddress', ''); + setValue('placeDetailAddress', ''); + setValue('latitude', 0); + setValue('longitude', 0); + setError('placeStreetAddress', { + type: 'required', + message: VENUE_REQUIRED_MESSAGE, + }); }} onDetailAddressChange={(value) => { setValue('placeDetailAddress', value); - if (value) { - clearErrors('placeDetailAddress'); - } }} /> diff --git a/apps/admin/src/hooks/useKakaoLocalSearch.ts b/apps/admin/src/hooks/useKakaoLocalSearch.ts index 24c31c22..ec7dfd42 100644 --- a/apps/admin/src/hooks/useKakaoLocalSearch.ts +++ b/apps/admin/src/hooks/useKakaoLocalSearch.ts @@ -42,6 +42,7 @@ export interface PlaceSearchResult { type: PlaceSearchResultType; id: string; placeName: string; + category: string; addressName: string; roadAddressName: string; x: string; @@ -64,6 +65,7 @@ const searchKeyword = async (query: string): Promise => { type: 'place' as const, id: doc.id, placeName: doc.place_name, + category: doc.category_group_name, addressName: doc.address_name, roadAddressName: doc.road_address_name, x: doc.x, @@ -87,6 +89,7 @@ const searchAddress = async (query: string): Promise => { type: 'address' as const, id: `addr-${index}-${doc.x}-${doc.y}`, placeName: '', + category: '', addressName: doc.address?.address_name ?? doc.address_name, roadAddressName: doc.road_address?.address_name ?? doc.address_name, x: doc.x,