프로젝트/호텔 검색 앱

호텔 검색 앱 3 - 검색어 자동완성 기능 구현하기

syleemomo 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