ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 호텔 검색 앱 3 - 검색어 자동완성 기능 구현하기
    프로젝트/호텔 검색 앱 2021. 11. 28. 23:49
    728x90

     

    * 자동완성을 실험하기 위한 가짜 데이터 저장하기

    const queryData = {
        "query": "서울",
        "moresuggestions": 10,
        "suggestions": [
            {
                "group": "CITY_GROUP",
                "entities": [
                    {
                        "geoId": "3124",
                        "destinationId": "759818",
                        "landmarkCityDestinationId": null,
                        "type": "CITY",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.565994,
                        "longitude": 126.982577,
                        "searchDetail": null,
                        "caption": "<span class='highlighted'>서울</span>, 한국",
                        "name": "서울"
                    },
                    {
                        "geoId": "6049712",
                        "destinationId": "1665648",
                        "landmarkCityDestinationId": null,
                        "type": "NEIGHBORHOOD",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.49746981209052,
                        "longitude": 127.06235814336002,
                        "searchDetail": null,
                        "caption": "강남, <span class='highlighted'>서울</span>, 한국",
                        "name": "강남"
                    },
                    {
                        "geoId": "6156735",
                        "destinationId": "1710405",
                        "landmarkCityDestinationId": null,
                        "type": "NEIGHBORHOOD",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.56372566629919,
                        "longitude": 126.98414822919044,
                        "searchDetail": null,
                        "caption": "명동, <span class='highlighted'>서울</span>, 한국",
                        "name": "명동"
                    },
                    {
                        "geoId": "179268",
                        "destinationId": "1712713",
                        "landmarkCityDestinationId": null,
                        "type": "NEIGHBORHOOD",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.594880968864516,
                        "longitude": 126.97734239320602,
                        "searchDetail": null,
                        "caption": "종로, <span class='highlighted'>서울</span>, 한국",
                        "name": "종로"
                    },
                    {
                        "geoId": "6347337",
                        "destinationId": "1812427",
                        "landmarkCityDestinationId": null,
                        "type": "NEIGHBORHOOD",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.505899761350655,
                        "longitude": 127.08251899429683,
                        "searchDetail": null,
                        "caption": "잠실동, <span class='highlighted'>서울</span>, 한국",
                        "name": "잠실동"
                    },
                    {
                        "geoId": "6222649",
                        "destinationId": "1747961",
                        "landmarkCityDestinationId": null,
                        "type": "REGION",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.52507275820544,
                        "longitude": 126.92598091344593,
                        "searchDetail": null,
                        "caption": "여의도, <span class='highlighted'>서울</span>, 한국",
                        "name": "여의도"
                    },
                    {
                        "geoId": "6049713",
                        "destinationId": "1665649",
                        "landmarkCityDestinationId": null,
                        "type": "NEIGHBORHOOD",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.543725187824165,
                        "longitude": 126.99024687030192,
                        "searchDetail": null,
                        "caption": "이태원, <span class='highlighted'>서울</span>, 한국",
                        "name": "이태원"
                    },
                    {
                        "geoId": "179262",
                        "destinationId": "1639122",
                        "landmarkCityDestinationId": null,
                        "type": "NEIGHBORHOOD",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.53143365798815,
                        "longitude": 126.98018807905429,
                        "searchDetail": null,
                        "caption": "용산구, <span class='highlighted'>서울</span>, 한국",
                        "name": "용산구"
                    }
                ]
            },
            {
                "group": "HOTEL_GROUP",
                "entities": [
                    {
                        "geoId": "15031",
                        "destinationId": "141401",
                        "landmarkCityDestinationId": null,
                        "type": "HOTEL",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.55629,
                        "longitude": 127.00449,
                        "searchDetail": null,
                        "caption": "<span class='highlighted'>서울</span> 신라호텔, 서울, 한국",
                        "name": "서울 신라호텔"
                    },
                    {
                        "geoId": "21727",
                        "destinationId": "106097",
                        "landmarkCityDestinationId": null,
                        "type": "HOTEL",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.54051,
                        "longitude": 126.99683,
                        "searchDetail": null,
                        "caption": "그랜드 하얏트 <span class='highlighted'>서울</span>, 서울, 한국",
                        "name": "그랜드 하얏트 서울"
                    },
                    {
                        "geoId": "24908",
                        "destinationId": "105343",
                        "landmarkCityDestinationId": null,
                        "type": "HOTEL",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.56395,
                        "longitude": 126.97953,
                        "searchDetail": null,
                        "caption": "웨스틴 조선 <span class='highlighted'>서울</span>, 서울, 한국",
                        "name": "웨스틴 조선 서울"
                    }
                ]
            },
            {
                "group": "LANDMARK_GROUP",
                "entities": [
                    {
                        "geoId": "6080001",
                        "destinationId": "1655737",
                        "landmarkCityDestinationId": "759818",
                        "type": "LANDMARK",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.45998329568354,
                        "longitude": 126.95299789150509,
                        "searchDetail": null,
                        "caption": "<span class='highlighted'>서울</span>대학교, 서울, 한국",
                        "name": "서울대학교"
                    },
                    {
                        "geoId": "6159377",
                        "destinationId": "1713589",
                        "landmarkCityDestinationId": "759818",
                        "type": "LANDMARK",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.575803,
                        "longitude": 126.976832,
                        "searchDetail": null,
                        "caption": "광화문, <span class='highlighted'>서울</span>, 한국",
                        "name": "광화문"
                    },
                    {
                        "geoId": "6228035",
                        "destinationId": "1750805",
                        "landmarkCityDestinationId": "759818",
                        "type": "LANDMARK",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.566215,
                        "longitude": 126.978505,
                        "searchDetail": null,
                        "caption": "<span class='highlighted'>서울</span>특별시청, 서울, 한국",
                        "name": "서울특별시청"
                    }
                ]
            },
            {
                "group": "TRANSPORT_GROUP",
                "entities": [
                    {
                        "geoId": "6164139",
                        "destinationId": "1718570",
                        "landmarkCityDestinationId": null,
                        "type": "METRO_STATION",
                        "redirectPage": "DEFAULT_PAGE",
                        "latitude": 37.555813,
                        "longitude": 126.972025,
                        "searchDetail": null,
                        "caption": "<span class='highlighted'>서울</span>역, 서울, 한국",
                        "name": "서울역"
                    }
                ]
            }
        ]
    }
    export default queryData

    src 폴더에 위와 같이 queryData.js 파일을 생성하고 가짜 데이터를 저장한다. 해당 데이터는 hotels.com 에서 제공하는 실제 데이터를 다운로드 한 것이다. 

     

    * 서버에서 데이터를 가져오는 헬퍼 함수 구현하기

    src 폴더에 lib 폴더를 생성하고 아래와 같은 파일들을 추가한다. 

    const fetchHotelsCom = async (url) => {
        try{
            return await fetch(url, {
                "method": "GET",
                "headers": {
                    "x-rapidapi-host": "hotels-com-provider.p.rapidapi.com",
                    "x-rapidapi-key": "26fb175c66msh4ef16cba57a9a16p1df633jsnb7dfe2a4b6c7"
                }
            }).then( res => res.json())
        }catch(e){
            return e
        }
    }
    export default fetchHotelsCom

    fetchHotelsCom.js 파일을 생성하고 위와 같이 작성하자! 위 코드는 hotels.com 서버에서 데이터를 가져올때 자주 사용하기 때문에 헬퍼 함수로 미리 정의해둔 다음에 사용한다. 

    const isArrayNull = (array) => {
        return array.length === 0
    }
    export { isArrayNull }

    helpers.js 파일을 생성하고 위와 같이 작성하자! 위 코드는 주어진 배열의 길이가 0 인지 여부를 검사하는 헬퍼 함수이다.

    export { default as fetchHotelsCom } from './fetchHotelsCom'
    export { isArrayNull } from './helpers'

    lib > index.js 파일을 생성하고 위와 같이 모듈을 내보낸다. 

     

    * 자동완성 메뉴를 위한 Caption, Button 컴포넌트 만들기

    components 폴더 하위에 아래 파일들을 추가한다.

    import React from 'react'
    import './Caption.css'
    
    const Caption = ({ id, destinationId, caption, setCaption, highlight }) => {
        return (
            <div className={`Caption-container ${highlight === id ? 'highlight' : ''}`} id={id} data-destinationid={destinationId} onClick={setCaption} dangerouslySetInnerHTML={{ __html: caption }}></div>
        )
    }
    
    export default Caption
    
    Caption.defaultProps = {
        highlight: 0
    }

    Caption.js 파일을 생성하고 위와 같이 작성하자!

    .Caption-container{
        border: 1px solid tan;
        box-sizing: border-box;
        padding: 10px;
        cursor: pointer;
        color: #a9a9a9;
        font-size: 1.2rem;
        font-weight: bold;
        font-family: Tahoma;
    }
    .Caption-container:hover{
        background: linear-gradient(rgba(255,255,255,0.2), rgba(255,255,255,0.2));
    }
    
    .highlight{
        background: linear-gradient(rgba(255,255,255,0.2), rgba(255,255,255,0.2));
    }

    Caption.css 파일을 생성하고 위와 같이 작성하자!

    import React from 'react'
    import './Button.css'
    
    const Button = ({ children, size, color, width, handleClick, disabled }) => {
        return <button 
                    className={`Button ${size} ${color} ${width}`} 
                    onClick={handleClick} disabled={disabled}
                >{children}</button>
    }
    
    export default Button;
    
    Button.defaultProps = {
        size: 'medium',
        color: 'tomato',
        disabled: false
    }

    Button.js 파일을 생성하고 위와 같이 작성하자!

    .Button {
        all: unset;
        color: white;
        cursor: pointer;
        border-radius: 5px;
        font-weight: bold;
        margin-left: 10px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        box-sizing: border-box;
    }
    
    .Button:hover{
        opacity: 0.7;
    }
    
    /* 버튼의 크기 설정 */
    .long{
        height: 60px;
        padding-left: 15px;
        padding-right: 15px;
        font-size: 1.2rem;
    }
    .medium{
        height: 50px;
        padding-left: 10px;
        padding-right: 10px;
        font-size: 1rem;
    }
    .short{
        height: 40px;
        padding-left: 5px;
        padding-right: 5px;
        font-size: 0.8rem;
    }
    
    /* 버튼의 배경색 설정 */
    .blue{
        background:blue;
    }
    .blue:hover{
        background: skyblue;
    }
    .tomato{
        background: tomato;
    }
    .tomato:hover{
        background: lightsalmon;
    }
    .grey{
        background: grey;
    }
    .grey:hover{
        background: lightgray;
    }
    /* 전체 너비를 차지하는 버튼 */
    .fullWidth{
        width: 100%;
        margin-left: 0px;
        margin-top: 10px;
        margin-bottom: 10px;
    }

    Button.css 파일을 생성하고 위와 같이 작성하자!

    export { default as Input } from './Input'
    export { default as Button } from './Button'
    export { default as Caption } from './Caption'

    components > index.js 파일을 위와 같이 수정하자!

     

    * 자동완성 기능 구현하기

    import React, { useState } from 'react'
    
    import { Input, Button, Caption } from 'components'
    import { fetchHotelsCom, isArrayNull } from 'lib'
    import { useNavigate } from 'react-router-dom'
    
    import queryData from '../queryData'
    
    import './Search.css'
    
    const Search = () => {
        const [destination, setDestination] = useState('')
        const [checkIn, setCheckIn] = useState('')
        const [checkOut, setCheckOut] = useState('')
        const [adultsNumber, setAdultsNumber] = useState(1) 
    
        const [captions, setCaptions] = useState([]) // 자동완성 메뉴 
        const [open, setOpen] = useState('hide')   // 자동완성 메뉴 온오프
        const [index, setIndex] = useState(0)     // 자동완성 메뉴 하이라이트 변경
        const [destinationId, setDestinationId] = useState(0)
    
        const navigate = useNavigate()
    
        const handleChange = (e) => {
            const { name, value } = e.target
            console.log(name, value)
    
            switch(name){
                case 'destination':
                    value ? setOpen('show') : setOpen('hide')
                    executeAutoCaption(value)
                    setDestination(value)
                    break;
                case 'check-in':
                    setCheckIn(value)
                    break;
                case 'check-out':
                    setCheckOut(value)
                    break;
                case 'adults-number':
                    setAdultsNumber(value)
                    break;
            }
            
    
        }
    
        // 자동완성 기능 구현
        const executeAutoCaption = async (query) => {
            //  const data = await getCaptions(query)
            //  const { suggestions } = data
            const { suggestions } = queryData
            const captionsItems = []
    
            if(!isArrayNull(suggestions)){
                suggestions.map(suggestion => {
                    const { entities } = suggestion
                    captionsItems.push(...entities)
                })
            }
            // console.log('captions: ',captionsItems)
            setCaptions(captionsItems)
            setHighlight() // 사용자 검색에 따라 하이라이트 변경
        }
        const getCaptions = async (query) => {
            console.log('get captions ...')
            // const data = await fetchHotelsCom(`https://hotels-com-provider.p.rapidapi.com/v1/destinations/search?query=${query}&currency=KRW&locale=ko_KR`)
            // return data
        }
        const setCaption = (e) => {
            const target = e.target.closest('.Caption-container')
            console.log(target)
            // console.log(target.dataset.destinationid)
    
            setDestination(target.innerText)
            setDestinationId(target.dataset.destinationid)
            setOpen('hide')
        }
        const setHighlight = () => {
            captions.map( (captionItem, id) => {
                captionItem.caption.includes(destination) ? setIndex(id) : null
            })
        }
        const changeCaptionHightlight = (e) => {
            // console.log(e.keyCode)
            const captionsLength = captions.length
    
            if(e.keyCode === 40){
                index < captionsLength - 1 ? setIndex(index+1) : setIndex(0)
            }else if(e.keyCode === 38){
                index > 0 ? setIndex(index-1) : setIndex(captionsLength - 1)
            }else if(e.keyCode === 13){
                const target = document.getElementById(index)
                console.log(target)
                // console.log(target.dataset.destinationid)
    
                setDestination(target.innerText)
                setDestinationId(target.dataset.destinationid)
                setOpen('hide')
            }
        }
    
        const searchHotels = () => {
            console.log('search hotels ...')
            console.log(destinationId, checkIn, checkOut, adultsNumber)
            navigate('/hotels', { state : { destinationId, checkIn, checkOut, adultsNumber } })
        }
    
        const Captions = ({captions}) => {
            let captionUI = null;
            // console.log('captionUI: ',captions)
            if(!isArrayNull(captions)){
                captionUI = captions.map( (captionItem, id) => {
                    return <Caption key={captionItem.destinationId} id={id} destinationId={captionItem.destinationId} caption={captionItem.caption} setCaption={setCaption} highlight={index}></Caption>
                })
            }
            return <>{captionUI}</>
        }
    
        return (
            <div className='Search-container'>
               <div className='Search-inputs'>
                    <div className='destination-container'>
                        <Input name='destination' type='text' placeholder='목적지를 입력하세요 ...' width='large' value={destination} onChange={handleChange} onKeyUp={changeCaptionHightlight}/>
                        <div className={`captions ${open}`}>{<Captions captions={captions}/>}</div>
                    </div>
                    <Input name='check-in' type='date' placeholder='체크인' width='small' value={checkIn} onChange={handleChange}/>
                    <Input name='check-out' type='date' placeholder='체크아웃' width='small' value={checkOut} onChange={handleChange}/> 
                    <Input name='adults-number' type='number' placeholder='인원수' width='middle' min={1} max={7} value={adultsNumber} onChange={handleChange}/>
                    <Button handleClick={searchHotels} color='blue' size='long'>검색</Button>
               </div>
            </div>
        )
    }
    
    export default Search

    Search.js 파일을 위와 같이 수정하자!

    .Search-container{
        width: 100%;
        height: 100vh;
        text-align: center;
        background-image: url('../assets/images/search.jpg');
        background-size: cover;
    }
    .Search-inputs{
        padding-top: 100px;
    
        display: flex;
        justify-content: center;
    }
    
    .captions{
        width: 500px;
        margin: 0 auto;
    }
    .show{
        display: block;
    }
    .hide{
        display: none;
    }

    Search.css 파일을 위와 같이 수정하자!

    728x90
Designed by Tistory.