프로젝트/호텔 검색 앱

호텔 검색 앱 4 - 호텔 목록 페이지 구현하기

syleemomo 2021. 12. 5. 22:16
728x90

 

* 호텔 목록을 보여주기 위한 가짜 데이터 저장하기

const hotelsData = {
    "header": "강남, 서울, 한국",
    "query": {
        "destination": {
            "id": "1665648",
            "value": "강남, 서울, 한국",
            "resolvedLocation": "NEIGHBORHOOD:1665648:UNKNOWN:UNKNOWN"
        }
    },
    "searchResults": {
        "totalCount": 2972,
        "results": [
            {
                "id": 106707,
                "name": "조선 팰리스 럭셔리 컬렉션 호텔, 서울 강남 (Josun Palace, a Luxury Collection Hotel, Seoul Gangnam)",
                "starRating": 5,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 테헤란로 231",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06142",
                    "region": "서울특별시",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.5,
                    "rating": "4.5",
                    "total": 81,
                    "scale": 5,
                    "badge": "superb",
                    "badgeText": "매우 훌륭함"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "0.7km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "2.0km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩495,000",
                        "exactCurrent": 495000,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩990,000</strong>)"
                    },
                    "features": {
                        "freeCancellation": false,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "EC"
                },
                "neighbourhood": "역삼",
                "deals": {},
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "HRW",
                "coordinate": {
                    "lat": 37.502642,
                    "lon": 127.041787
                },
                "providerType": "LOCAL",
                "supplierHotelId": 4871,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/1000000/10000/4900/4871/69fcb36b_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 116487,
                "name": "그랜드 인터컨티넨탈 서울 파르나스 (Grand InterContinental Seoul Parnas, an IHG Hotel)",
                "starRating": 5,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 테헤란로 521",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "135-732",
                    "region": "서울특별시",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.7,
                    "rating": "4.7",
                    "total": 685,
                    "scale": 5,
                    "badge": "exceptional",
                    "badgeText": "최고 좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.2km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "0.2km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩255,000",
                        "exactCurrent": 255000,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩510,000</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": true,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "코엑스",
                "deals": {},
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "DoubleStamps|TESCO",
                "coordinate": {
                    "lat": 37.50846,
                    "lon": 127.061036
                },
                "providerType": "LOCAL",
                "supplierHotelId": 22529,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/1000000/30000/22600/22529/4a3dd0ec_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 182415,
                "name": "인터컨티넨탈 서울 코엑스 (InterContinental Seoul COEX, an IHG Hotel)",
                "starRating": 5,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 봉은사로 524",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "135-975",
                    "region": "서울특별시",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.6,
                    "rating": "4.6",
                    "total": 933,
                    "scale": 5,
                    "badge": "superb",
                    "badgeText": "매우 훌륭함"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.2km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "0.8km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩160,000",
                        "exactCurrent": 160000,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩320,000</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": true,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "삼성동",
                "deals": {},
                "messaging": {
                    "scarcity": "저희 앱에서 1개 남았어요!"
                },
                "badging": {},
                "pimmsAttributes": "DoubleStamps|TESCO",
                "coordinate": {
                    "lat": 37.513481,
                    "lon": 127.057276
                },
                "roomsLeft": 1,
                "providerType": "LOCAL",
                "supplierHotelId": 424942,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/1000000/430000/425000/424942/29e80402_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 229056,
                "name": "파크 하얏트 서울 (Park Hyatt Seoul)",
                "starRating": 5,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 테헤란로 606",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "135-502",
                    "region": "서울특별시",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.5,
                    "rating": "4.5",
                    "total": 269,
                    "scale": 5,
                    "badge": "superb",
                    "badgeText": "매우 훌륭함"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.4km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "0.0km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩466,500",
                        "exactCurrent": 466500,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩933,000</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "EC"
                },
                "neighbourhood": "코엑스",
                "deals": {},
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "DoubleStamps|TESCO",
                "coordinate": {
                    "lat": 37.50845,
                    "lon": 127.06335
                },
                "providerType": "LOCAL",
                "supplierHotelId": 1200015,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/2000000/1210000/1200100/1200015/73dac5ab_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 1238009376,
                "name": "안다즈 서울 강남 (Andaz Seoul Gangnam)",
                "starRating": 5,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 신사동 논현로 854",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06022",
                    "region": "",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.5,
                    "rating": "4.5",
                    "total": 276,
                    "scale": 5,
                    "badge": "superb",
                    "badgeText": "매우 훌륭함"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "2.9km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "3.6km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩356,000",
                        "exactCurrent": 356000,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩712,000</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "EC"
                },
                "neighbourhood": "강남",
                "deals": {},
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "DoubleStamps|HRW|TESCO",
                "coordinate": {
                    "lat": 37.525265,
                    "lon": 127.028627
                },
                "providerType": "LOCAL",
                "supplierHotelId": 38656543,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/39000000/38660000/38656600/38656543/2c89da6c_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 626710,
                "name": "글래드 라이브 강남 (Glad Live Gangnam)",
                "starRating": 4,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 봉은사로 223",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06109",
                    "region": "",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.3,
                    "rating": "4.3",
                    "total": 376,
                    "scale": 5,
                    "badge": "fabulous",
                    "badgeText": "훌륭함"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.2km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "2.3km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩155,000",
                        "exactCurrent": 155000,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩310,000</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": true,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "논현동",
                "deals": {},
                "messaging": {
                    "scarcity": "저희 앱에서 2개 남았어요!"
                },
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|HRW|TESCO",
                "coordinate": {
                    "lat": 37.508184,
                    "lon": 127.036514
                },
                "roomsLeft": 2,
                "providerType": "LOCAL",
                "supplierHotelId": 16164397,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/17000000/16170000/16164400/16164397/d93a1f06_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 548967,
                "name": "호텔 카푸치노 (Hotel Cappuccino)",
                "starRating": 4,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 봉은사로 155",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06122",
                    "region": "",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.1,
                    "rating": "4.1",
                    "total": 483,
                    "scale": 5,
                    "badge": "very-good",
                    "badgeText": "매우 좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.5km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "2.8km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩123,341",
                        "exactCurrent": 123341,
                        "old": "₩190,341",
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩246,682</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": true,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "논현동",
                "deals": {
                    "secretPrice": {
                        "dealText": "비밀 가격으로 추가 할인받기"
                    },
                    "priceReasoning": "DRR-445"
                },
                "messaging": {
                    "scarcity": "저희 앱에서 3개 남았어요!"
                },
                "badging": {
                    "hotelBadge": {
                        "type": "vipBasic",
                        "label": "VIP"
                    }
                },
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.506641,
                    "lon": 127.031575
                },
                "roomsLeft": 3,
                "providerType": "LOCAL",
                "supplierHotelId": 12385513,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/13000000/12390000/12385600/12385513/4b7ae2b9_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 720758944,
                "name": "포 포인츠 바이 쉐라톤 강남 (Four Points by Sheraton Gangnam)",
                "starRating": 4,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 도산대로 203",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06026",
                    "region": "ICN",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.3,
                    "rating": "4.3",
                    "total": 372,
                    "scale": 5,
                    "badge": "fabulous",
                    "badgeText": "훌륭함"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "2.5km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "3.3km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩130,000",
                        "exactCurrent": 130000,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩260,000</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": true,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "강남",
                "deals": {},
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "DoubleStamps|TESCO",
                "coordinate": {
                    "lat": 37.520077,
                    "lon": 127.028287
                },
                "providerType": "LOCAL",
                "supplierHotelId": 22492467,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/23000000/22500000/22492500/22492467/6b3493d7_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 668653,
                "name": "유리앤 호텔 (Hotel Uri&)",
                "starRating": 4,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 삼성로96길 20",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06169",
                    "region": "",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.4,
                    "rating": "4.4",
                    "total": 407,
                    "scale": 5,
                    "badge": "fabulous",
                    "badgeText": "훌륭함"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "0.9km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "0.5km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩130,200",
                        "exactCurrent": 130200,
                        "old": "₩140,000",
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩260,400</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "코엑스",
                "deals": {
                    "secretPrice": {
                        "dealText": "비밀 가격으로 추가 할인받기"
                    },
                    "priceReasoning": "DRR-443"
                },
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.508976,
                    "lon": 127.057428
                },
                "providerType": "LOCAL",
                "supplierHotelId": 17484868,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/18000000/17490000/17484900/17484868/d75eb27a_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 333203,
                "name": "호텔 프리마 (Hotel Prima)",
                "starRating": 4,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 도산대로 536",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "135-100",
                    "region": "서울특별시",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 3.9,
                    "rating": "3.9",
                    "total": 234,
                    "scale": 5,
                    "badge": "good",
                    "badgeText": "좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "2.3km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "2.1km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩77,221",
                        "exactCurrent": 77221,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩154,442</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "EC"
                },
                "neighbourhood": "청담동",
                "deals": {},
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.52476,
                    "lon": 127.05227
                },
                "providerType": "MULTISOURCE",
                "supplierHotelId": 3041752,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/4000000/3050000/3041800/3041752/a24691b4_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 235442,
                "name": "베스트 웨스턴 프리미어 강남 (Best Western Premier Gangnam)",
                "starRating": 4,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 봉은사로 139",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "135-010",
                    "region": "서울특별시",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.3,
                    "rating": "4.3",
                    "total": 481,
                    "scale": 5,
                    "badge": "fabulous",
                    "badgeText": "훌륭함"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.7km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "2.9km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩114,865",
                        "exactCurrent": 114865,
                        "old": "₩191,442",
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩229,730</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": true,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "논현동",
                "deals": {
                    "specialDeal": {
                        "dealText": "40% 할인"
                    },
                    "priceReasoning": "DRR-446"
                },
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|priceRangeAU|TESCO",
                "coordinate": {
                    "lat": 37.506127,
                    "lon": 127.029857
                },
                "providerType": "LOCAL",
                "supplierHotelId": 1168148,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/2000000/1170000/1168200/1168148/87da40f3_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 439998,
                "name": "호텔 그라모스 (Hotel Grammos)",
                "starRating": 3.5,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 테헤란로33길 6-5",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "135-915",
                    "region": "서울특별시",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.2,
                    "rating": "4.2",
                    "total": 251,
                    "scale": 5,
                    "badge": "very-good",
                    "badgeText": "매우 좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "0.8km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "2.1km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩184,250",
                        "exactCurrent": 184250,
                        "old": "₩335,000",
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩368,500</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "EC"
                },
                "neighbourhood": "역삼",
                "deals": {
                    "secretPrice": {
                        "dealText": "비밀 가격으로 추가 할인받기"
                    },
                    "priceReasoning": "DRR-445"
                },
                "messaging": {
                    "scarcity": "저희 앱에서 4개 남았어요!"
                },
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.502447,
                    "lon": 127.039935
                },
                "roomsLeft": 4,
                "providerType": "LOCAL",
                "supplierHotelId": 6532238,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/7000000/6540000/6532300/6532238/5570dab1_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 1069704832,
                "name": "호텔 인 9 강남 (HOTEL in 9 Gangnam)",
                "starRating": 3.5,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 영동대로 618",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06081",
                    "region": "",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.4,
                    "rating": "4.4",
                    "total": 333,
                    "scale": 5,
                    "badge": "fabulous",
                    "badgeText": "훌륭함"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.5km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "0.8km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩147,491",
                        "exactCurrent": 147491,
                        "old": "₩189,091",
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩294,982</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": true,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "삼성동",
                "deals": {
                    "secretPrice": {
                        "dealText": "비밀 가격으로 추가 할인받기"
                    },
                    "priceReasoning": "DRR-445"
                },
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.515396,
                    "lon": 127.059892
                },
                "providerType": "LOCAL",
                "supplierHotelId": 33397026,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/34000000/33400000/33397100/33397026/24573d1c_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 1474882912,
                "name": "신라스테이 삼성 (Shilla Stay Samsung)",
                "starRating": 3.5,
                "urls": {},
                "address": {
                    "streetAddress": "영동대로 506",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06173",
                    "region": "서울특별시",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.2,
                    "rating": "4.2",
                    "total": 370,
                    "scale": 5,
                    "badge": "very-good",
                    "badgeText": "매우 좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.4km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "0.1km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩273,081",
                        "exactCurrent": 273080.5,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩546,161</strong>)"
                    },
                    "features": {
                        "freeCancellation": false,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "EC"
                },
                "neighbourhood": "코엑스",
                "deals": {},
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13",
                "coordinate": {
                    "lat": 37.509578,
                    "lon": 127.063196
                },
                "providerType": "MULTISOURCE",
                "supplierHotelId": 46058841,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/47000000/46060000/46058900/46058841/93be1b8a_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 692847744,
                "name": "글래드 강남 코엑스센터 (GLAD Gangnam COEX Center)",
                "starRating": 3.5,
                "urls": {},
                "address": {
                    "streetAddress": "테헤란로 610",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06174",
                    "region": "강남",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.4,
                    "rating": "4.4",
                    "total": 949,
                    "scale": 5,
                    "badge": "fabulous",
                    "badgeText": "훌륭함"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.4km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "0.1km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩180,000",
                        "exactCurrent": 180000,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩360,000</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": true,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "코엑스",
                "deals": {},
                "messaging": {
                    "scarcity": "저희 앱에서 1개 남았어요!"
                },
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.509131,
                    "lon": 127.064137
                },
                "roomsLeft": 1,
                "providerType": "LOCAL",
                "supplierHotelId": 21620242,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/22000000/21630000/21620300/21620242/955ed55d_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 1288351008,
                "name": "케이팝 코엑스 강남 스테이 (KPOP Coex Gangnam Stay)",
                "starRating": 3,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 테헤란로88길 10",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06179",
                    "region": "",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.7,
                    "rating": "4.7",
                    "total": 24,
                    "scale": 5,
                    "badge": "exceptional",
                    "badgeText": "최고 좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.0km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "0.3km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩80,909",
                        "exactCurrent": 80909,
                        "info": "유닛당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩161,818</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "EC"
                },
                "neighbourhood": "코엑스",
                "deals": {},
                "messaging": {
                    "scarcity": "저희 앱에서 1개 남았어요!"
                },
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13",
                "coordinate": {
                    "lat": 37.50693,
                    "lon": 127.0598
                },
                "roomsLeft": 1,
                "providerType": "LOCAL",
                "supplierHotelId": 40229719,
                "vrBadge": "아파트",
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/41000000/40230000/40229800/40229719/25fc57c3_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 550524,
                "name": "호텔 더 디자이너스 LYJ 강남 프리미어 (Hotel The Designers LYJ Gangnam Premier)",
                "starRating": 3,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 봉은사로 113",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06120",
                    "region": "",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 3.5,
                    "rating": "3.5",
                    "total": 177,
                    "scale": 5,
                    "badge": "good",
                    "badgeText": "좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "2.0km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "3.3km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩122,727",
                        "exactCurrent": 122727,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩245,454</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "논현동",
                "deals": {},
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.50495,
                    "lon": 127.02591
                },
                "providerType": "LOCAL",
                "supplierHotelId": 12465289,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/13000000/12470000/12465300/12465289/294d5777_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 783213792,
                "name": "라비타 호텔 (Lavita Hotel)",
                "starRating": 3,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 영동대로 712",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06075",
                    "region": "",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 3.9,
                    "rating": "3.9",
                    "total": 71,
                    "scale": 5,
                    "badge": "good",
                    "badgeText": "좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "2.0km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "1.6km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩82,802",
                        "exactCurrent": 82801.5,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩165,603</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "EC"
                },
                "neighbourhood": "청담동",
                "deals": {},
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.52139,
                    "lon": 127.05688
                },
                "providerType": "MULTISOURCE",
                "supplierHotelId": 24444181,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/25000000/24450000/24444200/24444181/b642e791_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 572035,
                "name": "블랑 호텔 강남 (Blanc Hotel Gangnam)",
                "starRating": 3,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 언주로94길 13",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "",
                    "region": "",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 3.1,
                    "rating": "3.1",
                    "total": 27,
                    "scale": 5,
                    "badge": "good",
                    "badgeText": "좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "0.5km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "1.8km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩73,554",
                        "exactCurrent": 73554,
                        "old": "₩81,727",
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩147,108</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "EC"
                },
                "neighbourhood": "역삼",
                "deals": {
                    "secretPrice": {
                        "dealText": "비밀 가격으로 추가 할인받기"
                    },
                    "priceReasoning": "DRR-443"
                },
                "messaging": {
                    "scarcity": "저희 앱에서 2개 남았어요!"
                },
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.504308,
                    "lon": 127.043244
                },
                "roomsLeft": 2,
                "providerType": "LOCAL",
                "supplierHotelId": 13456652,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/14000000/13460000/13456700/13456652/2126d241_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 237409,
                "name": "강남 패밀리 호텔 (Gangnam Family Hotel)",
                "starRating": 3,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 봉은사로 143",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "135-010",
                    "region": "서울특별시",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4,
                    "rating": "4.0",
                    "total": 149,
                    "scale": 5,
                    "badge": "very-good",
                    "badgeText": "매우 좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.6km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "2.9km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩85,909",
                        "exactCurrent": 85909,
                        "old": "₩127,273",
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩171,818</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": true,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "논현동",
                "deals": {
                    "secretPrice": {
                        "dealText": "비밀 가격으로 추가 할인받기"
                    },
                    "priceReasoning": "DRR-445"
                },
                "messaging": {},
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.506345,
                    "lon": 127.030365
                },
                "providerType": "LOCAL",
                "supplierHotelId": 1243595,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/2000000/1250000/1243600/1243595/bcef1f35_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 1275349600,
                "name": "어반엑시트 (UrbanExit)",
                "starRating": 2.5,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 역삼동 639-15 4층",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "135-080",
                    "region": "서울특별시",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.5km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "1.7km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩636,364",
                        "exactCurrent": 636364,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩1,272,728</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": true,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "삼성동",
                "deals": {},
                "messaging": {
                    "scarcity": "저희 앱에서 1개 남았어요!"
                },
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13",
                "coordinate": {
                    "lat": 37.517684,
                    "lon": 127.047561
                },
                "roomsLeft": 1,
                "providerType": "LOCAL",
                "supplierHotelId": 39823425,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/40000000/39830000/39823500/39823425/f3b740ea_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 505687,
                "name": "프린세스 호텔 (Princess Hotel)",
                "starRating": 2.5,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 압구정로46길 17",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "135896",
                    "region": "서울특별시",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4,
                    "rating": "4.0",
                    "total": 82,
                    "scale": 5,
                    "badge": "very-good",
                    "badgeText": "매우 좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "2.8km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "3.2km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩63,868",
                        "exactCurrent": 63868,
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩127,736</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": true,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "강남",
                "deals": {},
                "messaging": {
                    "scarcity": "저희 앱에서 2개 남았어요!"
                },
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.527413,
                    "lon": 127.036476
                },
                "roomsLeft": 2,
                "providerType": "LOCAL",
                "supplierHotelId": 10281147,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/11000000/10290000/10281200/10281147/1be56555_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 407941,
                "name": "베니키아 노블레스 호텔 (Benikea Hotel Noblesse)",
                "starRating": 2.5,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 역삼동 642-2",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "",
                    "region": "서울특별시",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 3.9,
                    "rating": "3.9",
                    "total": 33,
                    "scale": 5,
                    "badge": "good",
                    "badgeText": "좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.2km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "2.5km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩101,157",
                        "exactCurrent": 101157,
                        "old": "₩112,397",
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩202,314</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": true,
                        "noCCRequired": false
                    },
                    "type": "Dual"
                },
                "neighbourhood": "역삼",
                "deals": {
                    "secretPrice": {
                        "dealText": "비밀 가격으로 추가 할인받기"
                    },
                    "priceReasoning": "DRR-443"
                },
                "messaging": {
                    "scarcity": "저희 앱에서 4개 남았어요!"
                },
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.501162,
                    "lon": 127.035832
                },
                "roomsLeft": 4,
                "providerType": "LOCAL",
                "supplierHotelId": 4349551,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/5000000/4350000/4349600/4349551/22ec44b0_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 600484,
                "name": "스테이호텔 강남 (Stay Hotel Gangnam)",
                "starRating": 2.5,
                "urls": {},
                "address": {
                    "streetAddress": "논현로87길 15-4",
                    "extendedAddress": "강남구",
                    "locality": "서울특별시",
                    "postalCode": "06236",
                    "region": "",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.2,
                    "rating": "4.2",
                    "total": 244,
                    "scale": 5,
                    "badge": "very-good",
                    "badgeText": "매우 좋음"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "1.3km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "2.6km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩111,410",
                        "exactCurrent": 111410,
                        "old": "₩146,581",
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩222,820</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "EC"
                },
                "neighbourhood": "역삼",
                "deals": {
                    "specialDeal": {
                        "dealText": "24% 할인"
                    },
                    "priceReasoning": "DRR-446"
                },
                "messaging": {
                    "scarcity": "저희 앱에서 4개 남았어요!"
                },
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.499438,
                    "lon": 127.035615
                },
                "roomsLeft": 4,
                "providerType": "LOCAL",
                "supplierHotelId": 15412296,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/16000000/15420000/15412300/15412296/8e0c61da_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            },
            {
                "id": 592479,
                "name": "류게스트하우스 (Ryu Guest House)",
                "starRating": 2.5,
                "urls": {},
                "address": {
                    "streetAddress": "강남구 논현로159길 22",
                    "extendedAddress": "",
                    "locality": "서울특별시",
                    "postalCode": "06018",
                    "region": "",
                    "countryName": "대한민국",
                    "countryCode": "kr",
                    "obfuscate": false
                },
                "welcomeRewards": {
                    "collect": true
                },
                "guestReviews": {
                    "unformattedRating": 4.5,
                    "rating": "4.5",
                    "total": 29,
                    "scale": 5,
                    "badge": "superb",
                    "badgeText": "매우 훌륭함"
                },
                "landmarks": [
                    {
                        "label": "선릉역",
                        "distance": "2.9km"
                    },
                    {
                        "label": "삼성역",
                        "distance": "3.6km"
                    }
                ],
                "geoBullets": [],
                "ratePlan": {
                    "price": {
                        "current": "₩116,722",
                        "exactCurrent": 116722,
                        "old": "₩129,691",
                        "info": "객실당 1박 요금",
                        "summary": "세금 및 수수료 불포함",
                        "additionalInfo": "이는 선택하신 날짜의 1박 평균 요금입니다.",
                        "totalPricePerStay": "(2박 요금: <strong>₩233,444</strong>)"
                    },
                    "features": {
                        "freeCancellation": true,
                        "paymentPreference": false,
                        "noCCRequired": false
                    },
                    "type": "EC"
                },
                "neighbourhood": "신사동",
                "deals": {
                    "specialDeal": {
                        "dealText": "모바일 한정: 10% 할인"
                    },
                    "priceReasoning": "DRR-444"
                },
                "messaging": {
                    "scarcity": "저희 앱에서 1개 남았어요!"
                },
                "badging": {},
                "pimmsAttributes": "DoubleStamps|D13|TESCO",
                "coordinate": {
                    "lat": 37.52311,
                    "lon": 127.0262
                },
                "roomsLeft": 1,
                "providerType": "LOCAL",
                "supplierHotelId": 15157404,
                "isAlternative": false,
                "optimizedThumbUrls": {
                    "srpDesktop": "https://exp.cdn-hotels.com/hotels/16000000/15160000/15157500/15157404/8af1c158_z.jpg?impolicy=fcrop&w=250&h=140&q=high"
                }
            }
        ],
        "pagination": {
            "currentPage": 1,
            "pageGroup": "EXPEDIA_IN_POLYGON",
            "nextPageStartIndex": 25,
            "nextPageNumber": 2,
            "nextPageGroup": "EXPEDIA_IN_POLYGON"
        }
    },
    "sortResults": {
        "options": [
            {
                "label": "추천",
                "itemMeta": "popular",
                "choices": [
                    {
                        "label": "추천",
                        "value": "BEST_SELLER",
                        "selected": false
                    }
                ],
                "enhancedChoices": []
            },
            {
                "label": "숙박 시설 등급",
                "itemMeta": "star",
                "selectedChoiceLabel": "숙박 시설 등급(높은 순)",
                "choices": [
                    {
                        "label": "숙박 시설 등급(높은 순)",
                        "value": "STAR_RATING_HIGHEST_FIRST",
                        "selected": true
                    },
                    {
                        "label": "숙박 시설 등급(낮은 순)",
                        "value": "STAR_RATING_LOWEST_FIRST",
                        "selected": false
                    }
                ],
                "enhancedChoices": []
            },
            {
                "label": "거리",
                "itemMeta": "distance",
                "choices": [
                    {
                        "label": "도심까지의 거리",
                        "value": "DISTANCE_FROM_LANDMARK",
                        "selected": false
                    }
                ],
                "enhancedChoices": [
                    {
                        "label": "랜드마크",
                        "itemMeta": "landmarks",
                        "choices": [
                            {
                                "label": "LG 강남 타워",
                                "id": 10894757
                            },
                            {
                                "label": "LG 아트센터",
                                "id": 10679018
                            },
                            {
                                "label": "SMTOWN 코엑스아티움",
                                "id": 11130093
                            },
                            {
                                "label": "가로수길",
                                "id": 1713591
                            },
                            {
                                "label": "강남파이낸스센터",
                                "id": 10660398
                            },
                            {
                                "label": "교보 타워",
                                "id": 10601132
                            },
                            {
                                "label": "국기원",
                                "id": 10678912
                            },
                            {
                                "label": "도산공원",
                                "id": 10645186
                            },
                            {
                                "label": "봉은사",
                                "id": 1655786
                            },
                            {
                                "label": "비주얼아트센터 보다",
                                "id": 10441808
                            },
                            {
                                "label": "삼성타워팰리스",
                                "id": 1655788
                            },
                            {
                                "label": "선정릉",
                                "id": 1713683
                            },
                            {
                                "label": "세븐럭 카지노 강남점",
                                "id": 10440506
                            },
                            {
                                "label": "스타필드 코엑스몰",
                                "id": 10894751
                            },
                            {
                                "label": "압구정 로데오거리",
                                "id": 1711618
                            },
                            {
                                "label": "코엑스",
                                "id": 1655777
                            },
                            {
                                "label": "코엑스 아쿠아리움",
                                "id": 1713592
                            },
                            {
                                "label": "테헤란로",
                                "id": 1713585
                            },
                            {
                                "label": "한국자수박물관",
                                "id": 10644767
                            },
                            {
                                "label": "현대 백화점",
                                "id": 10461231
                            }
                        ]
                    },
                    {
                        "label": "역",
                        "itemMeta": "stations",
                        "choices": [
                            {
                                "label": "강남구청역",
                                "id": 1719302
                            },
                            {
                                "label": "개포동역",
                                "id": 1719102
                            },
                            {
                                "label": "구룡역",
                                "id": 1719103
                            },
                            {
                                "label": "대모산입구역",
                                "id": 1719101
                            },
                            {
                                "label": "대청역",
                                "id": 1719079
                            },
                            {
                                "label": "대치역",
                                "id": 1719077
                            },
                            {
                                "label": "도곡역",
                                "id": 1719076
                            },
                            {
                                "label": "매봉역",
                                "id": 1721137
                            },
                            {
                                "label": "봉은사역",
                                "id": 12676800
                            },
                            {
                                "label": "삼성역",
                                "id": 1719422
                            },
                            {
                                "label": "선릉역",
                                "id": 1719421
                            },
                            {
                                "label": "수서역",
                                "id": 1719081
                            },
                            {
                                "label": "신사역",
                                "id": 1719067
                            },
                            {
                                "label": "압구정역",
                                "id": 1719066
                            },
                            {
                                "label": "역삼역",
                                "id": 1719419
                            },
                            {
                                "label": "일원역",
                                "id": 1719080
                            },
                            {
                                "label": "청담역",
                                "id": 1719299
                            },
                            {
                                "label": "학동역",
                                "id": 1719307
                            },
                            {
                                "label": "학여울역",
                                "id": 1719078
                            },
                            {
                                "label": "한티역",
                                "id": 1719104
                            }
                        ]
                    },
                    {
                        "label": "공항",
                        "itemMeta": "airports",
                        "choices": [
                            {
                                "label": "서울 (GMP-김포국제공항)",
                                "id": 1665709
                            }
                        ]
                    }
                ]
            },
            {
                "label": "고객 평점",
                "itemMeta": "rating",
                "choices": [
                    {
                        "label": "고객 평점",
                        "value": "GUEST_RATING",
                        "selected": false
                    }
                ],
                "enhancedChoices": []
            },
            {
                "label": "가격",
                "itemMeta": "price",
                "choices": [
                    {
                        "label": "가격(높은 순)",
                        "value": "PRICE_HIGHEST_FIRST",
                        "selected": false
                    },
                    {
                        "label": "가격(낮은 순)",
                        "value": "PRICE",
                        "selected": false
                    }
                ],
                "enhancedChoices": []
            }
        ],
        "distanceOptionLandmarkId": 1665648
    },
    "filters": {
        "applied": true,
        "name": {
            "item": {
                "value": ""
            },
            "autosuggest": {
                "additionalUrlParams": {
                    "resolved-location": "NEIGHBORHOOD:1665648:UNKNOWN:UNKNOWN",
                    "q-destination": "강남, 서울, 한국",
                    "destination-id": "1665648"
                }
            }
        },
        "starRating": {
            "applied": false,
            "items": [
                {
                    "value": 5
                },
                {
                    "value": 4
                },
                {
                    "value": 3
                },
                {
                    "value": 2
                },
                {
                    "value": 1
                }
            ]
        },
        "guestRating": {
            "range": {
                "min": {
                    "defaultValue": 0
                },
                "max": {
                    "defaultValue": 5
                }
            }
        },
        "landmarks": {
            "selectedOrder": [],
            "items": [
                {
                    "label": "선릉역",
                    "value": "1719421"
                },
                {
                    "label": "삼성역",
                    "value": "1719422"
                },
                {
                    "label": "서울 (GMP-김포국제공항)",
                    "value": "1665709"
                },
                {
                    "label": "코엑스",
                    "value": "1655777"
                },
                {
                    "label": "가로수길",
                    "value": "1713591"
                },
                {
                    "label": "수서역",
                    "value": "1719081"
                },
                {
                    "label": "신사역",
                    "value": "1719067"
                },
                {
                    "label": "역삼역",
                    "value": "1719419"
                },
                {
                    "label": "강남구청역",
                    "value": "1719302"
                },
                {
                    "label": "압구정 로데오거리",
                    "value": "1711618"
                },
                {
                    "label": "압구정역",
                    "value": "1719066"
                },
                {
                    "label": "봉은사",
                    "value": "1655786"
                },
                {
                    "label": "선정릉",
                    "value": "1713683"
                },
                {
                    "label": "스타필드 코엑스몰",
                    "value": "10894751"
                },
                {
                    "label": "청담역",
                    "value": "1719299"
                },
                {
                    "label": "일원역",
                    "value": "1719080"
                },
                {
                    "label": "학동역",
                    "value": "1719307"
                },
                {
                    "label": "대치역",
                    "value": "1719077"
                },
                {
                    "label": "학여울역",
                    "value": "1719078"
                },
                {
                    "label": "테헤란로",
                    "value": "1713585"
                }
            ],
            "distance": []
        },
        "neighbourhood": {
            "applied": false,
            "items": [
                {
                    "label": "강남",
                    "value": "1665648"
                },
                {
                    "label": "코엑스",
                    "value": "1773305"
                },
                {
                    "label": "삼성동",
                    "value": "1713605"
                },
                {
                    "label": "역삼",
                    "value": "1772395"
                },
                {
                    "label": "청담동",
                    "value": "1805581"
                },
                {
                    "label": "압구정",
                    "value": "1711619"
                },
                {
                    "label": "신사동",
                    "value": "11129227"
                },
                {
                    "label": "논현동",
                    "value": "11114222"
                },
                {
                    "label": "도곡동",
                    "value": "10584556"
                },
                {
                    "label": "개포동",
                    "value": "12547821"
                },
                {
                    "label": "대치동",
                    "value": "12549439"
                }
            ]
        },
        "accommodationType": {
            "applied": false,
            "items": [
                {
                    "label": "게스트하우스",
                    "value": "30"
                },
                {
                    "label": "리조트",
                    "value": "3"
                },
                {
                    "label": "모텔",
                    "value": "7"
                },
                {
                    "label": "빌라",
                    "value": "14"
                },
                {
                    "label": "아파트",
                    "value": "15"
                },
                {
                    "label": "아파트식 호텔",
                    "value": "20"
                },
                {
                    "label": "인/여관",
                    "value": "8"
                },
                {
                    "label": "캐러밴 파크",
                    "value": "21"
                },
                {
                    "label": "캐빈 & 로지",
                    "value": "9"
                },
                {
                    "label": "캡슐 호텔",
                    "value": "43"
                },
                {
                    "label": "코티지",
                    "value": "11"
                },
                {
                    "label": "펜션",
                    "value": "25"
                },
                {
                    "label": "호스텔",
                    "value": "12"
                },
                {
                    "label": "호텔",
                    "value": "1"
                },
                {
                    "label": "휴가용 주택",
                    "value": "4"
                }
            ]
        },
        "facilities": {
            "applied": false,
            "items": [
                {
                    "label": "24시간 운영 프런트 데스크",
                    "value": "2063"
                },
                {
                    "label": "WiFi 포함",
                    "value": "527"
                },
                {
                    "label": "객실 내 욕조",
                    "value": "517"
                },
                {
                    "label": "객실 연결 가능",
                    "value": "523"
                },
                {
                    "label": "공항 교통편",
                    "value": "513"
                },
                {
                    "label": "금연",
                    "value": "529"
                },
                {
                    "label": "레스토랑",
                    "value": "256"
                },
                {
                    "label": "바",
                    "value": "515"
                },
                {
                    "label": "반려동물 동반 가능",
                    "value": "64"
                },
                {
                    "label": "비즈니스 시설",
                    "value": "519"
                },
                {
                    "label": "수영장",
                    "value": "128"
                },
                {
                    "label": "스키 보관 시설",
                    "value": "533"
                },
                {
                    "label": "스키 셔틀",
                    "value": "531"
                },
                {
                    "label": "스키 타고 출입 가능",
                    "value": "535"
                },
                {
                    "label": "스파",
                    "value": "539"
                },
                {
                    "label": "아침 식사 포함",
                    "value": "2048"
                },
                {
                    "label": "유아용 침대 제공",
                    "value": "525"
                },
                {
                    "label": "인터넷",
                    "value": "8"
                },
                {
                    "label": "전기차 충전",
                    "value": "1073743315"
                },
                {
                    "label": "주방",
                    "value": "32"
                },
                {
                    "label": "주차 가능",
                    "value": "16384"
                },
                {
                    "label": "주차 포함",
                    "value": "134234112"
                },
                {
                    "label": "카지노",
                    "value": "2112"
                },
                {
                    "label": "탁아 서비스",
                    "value": "521"
                },
                {
                    "label": "피트니스",
                    "value": "2"
                },
                {
                    "label": "회의 시설",
                    "value": "1"
                },
                {
                    "label": "흡연 가능",
                    "value": "537"
                }
            ]
        },
        "accessibility": {
            "applied": false,
            "items": [
                {
                    "label": "객실 내 장애인 편의 시설",
                    "value": "1048576"
                },
                {
                    "label": "롤인 샤워(휠체어 이용)",
                    "value": "262144"
                },
                {
                    "label": "배리어 프리",
                    "value": "65536"
                },
                {
                    "label": "장애인 지원 욕실",
                    "value": "131072"
                },
                {
                    "label": "장애인 지원 주차",
                    "value": "524288"
                },
                {
                    "label": "점자 지원",
                    "value": "4194304"
                },
                {
                    "label": "청각 장애인 지원 장비",
                    "value": "2097152"
                },
                {
                    "label": "휠체어 이용 가능 객실",
                    "value": "541"
                }
            ]
        },
        "themesAndTypes": {
            "applied": false,
            "items": [
                {
                    "label": "가족 여행",
                    "value": "25"
                },
                {
                    "label": "골프",
                    "value": "26"
                },
                {
                    "label": "럭셔리",
                    "value": "15"
                },
                {
                    "label": "로맨틱",
                    "value": "1"
                },
                {
                    "label": "부티크",
                    "value": "4"
                },
                {
                    "label": "비즈니스",
                    "value": "14"
                },
                {
                    "label": "성소수자 환영",
                    "value": "21"
                },
                {
                    "label": "쇼핑",
                    "value": "17"
                },
                {
                    "label": "스키",
                    "value": "28"
                },
                {
                    "label": "스파",
                    "value": "27"
                },
                {
                    "label": "어드벤처",
                    "value": "18"
                },
                {
                    "label": "온천",
                    "value": "22"
                },
                {
                    "label": "와이너리/포도밭",
                    "value": "19"
                },
                {
                    "label": "카지노",
                    "value": "8"
                }
            ]
        },
        "price": {
            "label": "1박 요금",
            "range": {
                "min": {
                    "defaultValue": 0,
                    "value": 0
                },
                "max": {
                    "defaultValue": 1000000
                },
                "increments": 10000
            },
            "multiplier": 1,
            "applied": true
        },
        "paymentPreference": {
            "items": [
                {
                    "label": "무료 취소",
                    "value": "fc"
                }
            ]
        },
        "welcomeRewards": {
            "label": "Hotels.com™ 호텔스닷컴 리워드",
            "items": [
                {
                    "label": "스탬프 적립",
                    "value": "collect"
                },
                {
                    "label": "리워드* 숙박 사용",
                    "value": "redeem"
                }
            ]
        }
    },
    "pointOfSale": {
        "currency": {
            "code": "KRW",
            "symbol": "₩",
            "separators": ",.",
            "format": "₩{0}"
        }
    }
}


export default hotelsData

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

 

* 목적지 ID 를 이용하여 API 서버에서 호텔 목록 가져오기

import React from 'react'
import { useLocation } from 'react-router-dom'

import { fetchHotelsCom, isArrayNull } from 'lib'
import hotelsData from '../hotelsData'

const Hotels = () => {
    const location = useLocation()
    const { destinationId, checkIn, checkOut, adultsNumber } = location.state
    console.log(destinationId, checkIn, checkOut, adultsNumber)

    const getHotels = async () => {
        // const data = await fetchHotelsCom(`https://hotels-com-provider.p.rapidapi.com/v1/hotels/search?checkin_date=${checkIn}&checkout_date=${checkOut}&sort_order=STAR_RATING_HIGHEST_FIRST&destination_id=${destinationId}&adults_number=${adultsNumber}&locale=ko_KR&currency=KRW`)
        // console.log(data)

        const {searchResults: {results}} = hotelsData
        console.log(results)
        
        return results
    }

    return (
        <div>Hotels Page</div>
    )
}

export default Hotels

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

import { fetchHotelsCom, isArrayNull } from 'lib'

hotels.com API 서버에서 데이터를 가져오기 위하여 관련된 함수를 임포트한다. 

import hotelsData from '../hotelsData'

호텔 검색 결과에 대한 가짜 데이터를 임포트한다.

const getHotels = async () => {
    // const data = await fetchHotelsCom(`https://hotels-com-provider.p.rapidapi.com/v1/hotels/search?checkin_date=${checkIn}&checkout_date=${checkOut}&sort_order=STAR_RATING_HIGHEST_FIRST&destination_id=${destinationId}&adults_number=${adultsNumber}&locale=ko_KR&currency=KRW`)
    // console.log(data)

    const {searchResults: {results}} = hotelsData
    console.log(results)

    return results
}

검색된 호텔 목록을 API 서버로부터 가져온다. 현재는 hotelsData 라는 가짜 데이터를 사용하여 해당 데이터로부터 호텔 목록에 대한 정보를 조회한 다음 반환한다. 

 

* 호텔 목록 정보를 화면에 보여주기- 리액트 훅 사용하기

import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'

import { fetchHotelsCom, isArrayNull } from 'lib'
import hotelsData from '../hotelsData'

const Hotels = () => {
    const location = useLocation()
    const { destinationId, checkIn, checkOut, adultsNumber } = location.state
    console.log(destinationId, checkIn, checkOut, adultsNumber)

    const [hotels, setHotels] = useState([])

    useEffect( async () => {
        const hotelsList = await getHotels()
        setHotels(hotelsList)
    }, [])

    const getHotels = async () => {
        // const data = await fetchHotelsCom(`https://hotels-com-provider.p.rapidapi.com/v1/hotels/search?checkin_date=${checkIn}&checkout_date=${checkOut}&sort_order=STAR_RATING_HIGHEST_FIRST&destination_id=${destinationId}&adults_number=${adultsNumber}&locale=ko_KR&currency=KRW`)
        // console.log(data)

        const {searchResults: {results}} = hotelsData
        console.log(results)
        
        return results
    }

    return (
        <div>Hotels Page</div>
    )
}

export default Hotels

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

import React, { useState, useEffect } from 'react'

리액트 훅을 사용하기 위하여 해당 함수를 임포트한다. 

const [hotels, setHotels] = useState([])

hotels.com API 서버로부터 가져온 호텔 목록 정보를 화면에 보여주기 위하여 hotels 라는 상태를 선언하다.

useEffect( async () => {
    const hotelsList = await getHotels()
    setHotels(hotelsList)
}, [])

Effect Hook 을 사용하여 서버로부터 호텔 목록에 대한 데이터를 가져온 다음, hotels 상태를 해당 데이터로 업데이트한다. 

 

* 호텔 목록 정보를 화면에 보여주기- HotelItem 컴포넌트 만들기

const isArrayNull = (array) => {
    return array.length === 0
}
const handleNullObj = (obj) => {
    return obj || {}
}
export { isArrayNull, handleNullObj }

lib > helpers.js 파일을 위와 같이 수정하자! 

const handleNullObj = (obj) => {
    return obj || {}
}

handleNullObj 이라는 헬퍼함수를 추가한다. 해당 함수는 인자로 전달된 객체가 undefined 이면 빈 객체를 반환한다.

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

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

 

components 폴더 하위에 아래와 같은 파일을 생성하자!

import React from 'react'
import { Link } from 'react-router-dom'

import { isArrayNull, handleNullObj } from 'lib'
import './HotelItem.css'

const HotelItem = ({ hotel }) => {
    const { id, name,  optimizedThumbUrls, starRating, address, landmarks, guestReviews, ratePlan, neighbourhood } = handleNullObj(hotel)
    const { srpDesktop } = handleNullObj(optimizedThumbUrls)
    const { streetAddress, locality, postalCode, countryName} = handleNullObj(address)
    const { rating, badgeText} = handleNullObj(guestReviews)
    const { price } = handleNullObj(ratePlan)
    const { old, current, info, summary, totalPricePerStay} = handleNullObj(price)
    const totalPrice = totalPricePerStay? totalPricePerStay.split(/[<>()]/) : []

    return (<div className='HotelItem-container'>
                <Link className='HotelItem-thumbnail' to='/hotelInfo'>
                    <img className='HotelItem-thumbnail-img' src={srpDesktop} alt={name}/>
                </Link>
                <div className='HotelItem-info'>
                    <div className='HotelItem-name'>{name} <span>{starRating}성급</span></div>
                    <div className='HotelItem-address'>{streetAddress}, {locality}, {countryName}</div>
                    <div className='HotelItem-neighbourhood'>{neighbourhood}</div>
                    <div className='HotelItem-landmarks'>
                        {!isArrayNull(landmarks) && landmarks.map( (landmark, index) => {
                            return <div key={index}>* {landmark.label}까지 {landmark.distance}</div>
                        })}
                    </div>
                    <div className='HotelItem-rating'>
                        <div className={`HotelItem-rating-badge ${parseInt(rating) < 8 ? 'HotelItem-rating-badge-gray' : ''}`}>{rating}</div>
                        <div className='HotelItem-rating-badgeText'> {badgeText}</div>
                    </div>
                </div>
                <div className='HotelItem-price'>
                    <div className='HotelItem-price-per-oneday'><span>{old}</span> {current}</div>
                    <div className='HotelItem-price-per-oneday-title'>{info}</div>
                    <div className='HotelItem-price-total'>{totalPrice[1]} {totalPrice[3]}</div>
                    <div className='HotelItem-price-summary'>{summary}</div>
                </div>
            </div>)
}
export default HotelItem

HotelItem.js 파일을 위와 같이 생성하자!

import { Link } from 'react-router-dom'

호텔 썸네일을 클릭하면 호텔에 대한 자세한 정보가 표시되는 페이지로 이동하기 위하여 Link 컴포넌트를 임포트한다.

import { isArrayNull, handleNullObj } from 'lib'

호텔 목록 정보에 대한 유효성 검증을 하기 위하여 lib 폴더로부터 헬퍼함수를 임포트한다. 

const { id, name,  optimizedThumbUrls, starRating, address, landmarks, guestReviews, ratePlan, neighbourhood } = handleNullObj(hotel)

특정 호텔 정보로부터 필요한 프로퍼티를 조회한다. 호텔의 ID, 이름, 썸네일, 등급, 주소, 랜드마크, 리뷰, 숙박가격, 가까운 동네 등을 조회한다.

const { srpDesktop } = handleNullObj(optimizedThumbUrls)

호텔 썸네일 링크를 조회한다.

const { streetAddress, locality, postalCode, countryName} = handleNullObj(address)

호텔 주소를 조회한다.

const { rating, badgeText} = handleNullObj(guestReviews)

호텔 리뷰를 조회한다.

const { price } = handleNullObj(ratePlan)
const { old, current, info, summary, totalPricePerStay} = handleNullObj(price)

호텔의 가격을 조회한다. old 는 할인전 가격, current 는 할인후 가격을 의미한다.

const totalPrice = totalPricePerStay? totalPricePerStay.split(/[<>()]/) : []

호텔의 최종 숙박가격을 조회한다. totalPricePerStay 값은 원하지 않는 포맷으로 되어 있어서 split 함수와 정규표현식을 사용해서 파싱한 다음 화면에 보여준다. 

<Link className='HotelItem-thumbnail' to='/hotelInfo'>
    <img className='HotelItem-thumbnail-img' src={srpDesktop} alt={name}/>
</Link>

호텔의 썸네일을 화면에 렌더링한다. Link 컴포넌트를 사용하여 썸네일을 클릭하면 해당 호텔에 대한 자세한 정보를 보여주는 페이지로 이동한다.

<div className='HotelItem-info'>
    <div className='HotelItem-name'>{name} <span>{starRating}성급</span></div>
    <div className='HotelItem-address'>{streetAddress}, {locality}, {countryName}</div>
    <div className='HotelItem-neighbourhood'>{neighbourhood}</div>
    <div className='HotelItem-landmarks'>
        {!isArrayNull(landmarks) && landmarks.map( (landmark, index) => {
            return <div key={index}>* {landmark.label}까지 {landmark.distance}</div>
        })}
    </div>
    <div className='HotelItem-rating'>
        <div className={`HotelItem-rating-badge ${parseInt(rating) < 8 ? 'HotelItem-rating-badge-gray' : ''}`}>{rating}</div>
        <div className='HotelItem-rating-badgeText'> {badgeText}</div>
    </div>
</div>

호텔의 기본 정보를 화면에 렌더링한다. 호텔 이름, 등급, 주소, 가까운 동네, 리뷰 등을 화면에 보여준다. rating 값이 8 보다 작으면 회색 배지로 보여주고, 8보다 크면 녹색 배지로 보여준다.

<div className='HotelItem-price'>
    <div className='HotelItem-price-per-oneday'><span>{old}</span> {current}</div>
    <div className='HotelItem-price-per-oneday-title'>{info}</div>
    <div className='HotelItem-price-total'>{totalPrice[1]} {totalPrice[3]}</div>
    <div className='HotelItem-price-summary'>{summary}</div>
</div>

호텔 숙박가격을 화면에 렌더링한다. 호텔의 할인전 가격, 할인후 가격, 총 지불비용 등을 화면에 보여준다. 

.HotelItem-container{
    display: flex;
    border-bottom: 1px solid lightgray;
}
.HotelItem-thumbnail{
    width: 250px;
    height: 140px;
    overflow: hidden;
    border-radius: 5px;
    margin: 10px;
}
.HotelItem-thumbnail-img{
    width: 100%;
    height: 100%;
}
.HotelItem-info{
    box-sizing: border-box;
    padding: 10px;
    flex: 1;
}
.HotelItem-name{
    font-size: 1.3rem;
    font-weight: bold;
}
.HotelItem-name span{
    font-size: 0.9rem;
    color: red;
}
.HotelItem-address{
    color: #525252;
}
.HotelItem-neighbourhood{
    margin-top: 10px;
    margin-bottom: 5px;
    font-weight: bold;
    font-size: 1.1rem;
}
.HotelItem-landmarks{
    font-size: 0.9rem;
}
.HotelItem-rating{
    display: flex;
    align-items: flex-end;
    margin-top: 10px;
}
.HotelItem-rating-badge{
    background-color: #218242;
    color: white;
    padding-left: 5px;
    padding-right: 5px;
    padding-bottom: 2px;
    border-radius: 2px;
}
.HotelItem-rating-badgeText{
    font-size: 1rem;
    font-weight: bold;
    margin-left: 10px;
}
.HotelItem-price{
    box-sizing: border-box;
    padding: 10px;
    width: 250px;

    display: flex;
    flex-direction: column;
    justify-content: flex-start;
    align-items: flex-end;
}
.HotelItem-price-per-oneday{
    font-size: 1.5rem;
    font-weight: bold;
}
.HotelItem-price-per-oneday span{
    font-size: 0.8rem;
    text-decoration: line-through;
}
.HotelItem-price-per-oneday-title{
    font-size: 0.8rem;
    color: #525252;
}
.HotelItem-price-total{
    font-weight: bold;
    margin-top: 10px;
}
.HotelItem-price-summary{
    font-size: 0.8rem;
    color: #525252;
}

HotelItem.css 파일을 위와 같이 생성하자!

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

components > index.js 파일에 HotelItem 컴포넌트도 추가로 내보내도록 한다. 

 

import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'

import { fetchHotelsCom, isArrayNull } from 'lib'
import hotelsData from '../hotelsData'
import { HotelItem } from 'components'

import './Hotels.css'

const Hotels = () => {
    const location = useLocation()
    const { destinationId, checkIn, checkOut, adultsNumber } = location.state
    console.log(destinationId, checkIn, checkOut, adultsNumber)

    const [hotels, setHotels] = useState([])

    useEffect( async () => {
        const hotelsList = await getHotels()
        setHotels(hotelsList)
    }, [])

    const getHotels = async () => {
        // const data = await fetchHotelsCom(`https://hotels-com-provider.p.rapidapi.com/v1/hotels/search?checkin_date=${checkIn}&checkout_date=${checkOut}&sort_order=STAR_RATING_HIGHEST_FIRST&destination_id=${destinationId}&adults_number=${adultsNumber}&locale=ko_KR&currency=KRW`)
        // console.log(data)

        const {searchResults: {results}} = hotelsData
        console.log(results)
        
        return results
    }

    return (
        <div className='Hotels-container'>
            {!isArrayNull(hotels) && hotels.map( hotel => {
                return (
                    <HotelItem hotel={hotel} key={hotel.id}/>
                )
            })}
        </div>
    )
}

export default Hotels

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

<div className='Hotels-container'>
    {!isArrayNull(hotels) && hotels.map( hotel => {
        return (
            <HotelItem hotel={hotel} key={hotel.id}/>
        )
    })}
</div>

hotels 상태는 전체 호텔 목록에 대한 정보를 담고 있다. hotels 배열에 요소가 하나 이상 있으면 호텔 목록 정보를 화면에 보여준다. HotelItem 컴포넌트로 hotel 이라는 props 를 전달한다. 

.Hotels-container{
    width: 45%;
    margin: 0 auto;
}

pages > Hotels.css 파일을 위와 같이 생성하자! 아래와 같은 화면이 보이면 제대로 된 것이다. 

호텔 목록 페이지 화면

 

 

* 호텔 위치를 보여줄 지도 추가하기

 

지도 라이브러리 참고문서

 

Quick Start Guide - Leaflet - a JavaScript library for interactive maps

← Tutorials Leaflet Quick Start Guide This step-by-step guide will quickly get you started on Leaflet basics, including setting up a Leaflet map, working with markers, polylines and popups, and dealing with events. Preparing your page Before writing any

leafletjs.com

 

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>호텔 검색 서비스</title>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
   integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
   crossorigin=""/>
   <script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
   integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
   crossorigin=""></script>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

 

 

src > index.html 파일의 head 부분에 관련 라이브러리를 추가한다. 

.Hotels-container{
    width: 45%;
    margin: 0 auto;
}
#map { height: 400px; }

pages > Hotels.css 파일에 지도의 크기를 설정해준다.

import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'

import { fetchHotelsCom, isArrayNull, handleNullObj } from 'lib'
import hotelsData from '../hotelsData'
import { HotelItem } from 'components'

import './Hotels.css'

const Hotels = () => {
    const location = useLocation()
    const { destinationId, checkIn, checkOut, adultsNumber } = location.state
    console.log(destinationId, checkIn, checkOut, adultsNumber)

    const [hotels, setHotels] = useState([])
    const [mapObj, setMapObj] = useState(null)

    useEffect( async () => {
        const hotelsList = await getHotels()
        setHotels(hotelsList)
        const m = L.map('map')
        setMapObj(m)
    }, [])

    const getHotels = async () => {
        // const data = await fetchHotelsCom(`https://hotels-com-provider.p.rapidapi.com/v1/hotels/search?checkin_date=${checkIn}&checkout_date=${checkOut}&sort_order=STAR_RATING_HIGHEST_FIRST&destination_id=${destinationId}&adults_number=${adultsNumber}&locale=ko_KR&currency=KRW`)
        // console.log(data)

        const {searchResults: {results}} = hotelsData
        console.log(results)
        
        return results
    }

    
    const displayLocation = (lat, lon, msg) => {
        console.log('inside:', mapObj)

        if(mapObj){
            const map = mapObj.setView([lat, lon], 13)

            L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
                attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
            }).addTo(map)

            L.marker([lat, lon]).addTo(map)
                .bindPopup(msg)
                .openPopup()
        }
    }

    return (
        <div className='Hotels-container'>
            <div id="map"></div>
            {!isArrayNull(hotels) && hotels.map( hotel => {
                const { name, address, coordinate } = handleNullObj(hotel)
                const { streetAddress, locality, countryName } = handleNullObj(address)
                const { lat, lon } = handleNullObj(coordinate)
                const msg = `${name}<br/>${streetAddress}, ${locality}, ${countryName}`
                displayLocation(lat, lon, msg)
                return (
                    <HotelItem hotel={hotel} key={hotel.id}/>
                )
            })}
        </div>
    )
}

export default Hotels

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

import { fetchHotelsCom, isArrayNull, handleNullObj } from 'lib'

데이터 유효성 검증을 위하여 handleNullObj 함수를 추가로 임포트한다. 해당 함수는 인자로 들어온 값이 undefined 이면 빈 객체를 반환한다. 

 const [mapObj, setMapObj] = useState(null)

라이브러리에서 사용되는 지도 객체를 조회할 상태값(mapObj)을 선언한다. 

 useEffect( async () => {
     const hotelsList = await getHotels()
     setHotels(hotelsList)
        const m = L.map('map')
        setMapObj(m)
 }, [])
const m = L.map('map')
setMapObj(m)

라이브러리의 지도 객체를 useEffect 함수 외부에서 사용하기 위하여 mapObj 상태에 저장한다.

const displayLocation = (lat, lon, msg) => {
    console.log('inside:', mapObj)

    if(mapObj){
        const map = mapObj.setView([lat, lon], 13)

        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        }).addTo(map)

        L.marker([lat, lon]).addTo(map)
            .bindPopup(msg)
            .openPopup()
    }
}

mapObj 는 상태값이므로 초기값이 null 로 설정되어 있다. 그러므로 null 이 아닌 실제 지도 객체가 저장되면 그때 지도를 보여준다. 

const map = mapObj.setView([lat, lon], 13)

setView 메서드의 첫번째 인자로 배열이 들어간다. 배열요소로 위도(latitude)와 경도(longitude) 값이 설정된다. 이렇게 하면 지도의 중앙에 설정한 위도와 경도가 표시된다. 13 이라는 숫자는 zoom level 을 의미한다. 말그대로 줌의 등급을 나타낸다. 자세하게 줌을 하거나 광범위하게 줌을 한다. 

L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map)

위 코드는 지도의 배경화면을 구성하는 종류와 크기를 설정한다.

L.marker([lat, lon]).addTo(map)
                .bindPopup(msg)
                .openPopup()

위 코드는 인자로 전달된 위도(lat)와 경도(lon)를 사용하여 마커(marker)로 지도에 위치를 표시한다. bindPopup, openPopup 메서드는 마커에서 보여줄 메세지를 표시한다. 

 <div id="map"></div>

화면에 지도를 렌더링한다.

const { name, address, coordinate } = handleNullObj(hotel)

hotel 객체로부터 호텔의 이름(name), 주소(address), 위치(coordinate)를 조회한다. 

const { streetAddress, locality, countryName } = handleNullObj(address)

호텔의 주소 정보(address)로부터 호텔이 위치한 거리(streetAddress), 도시(locality), 국가(countryName) 정보를 추출한다. 

const { lat, lon } = handleNullObj(coordinate)

호텔의 위치 정보(coordinate)로부터 위도(lat)와 경도(lon)를 추출한다. 

const msg = `${name}<br/>${streetAddress}, ${locality}, ${countryName}`

지도 마커(marker)에 표시할 메세지를 생성한다. 

 displayLocation(lat, lon, msg)

위 함수를 실행하여 지도에 해당 호텔의 위치를 추가한다.

호텔의 위치를 지도에 표시한 모습

 

 

* 호텔 목록 페이지에 필터 적용하기

components 폴더에 아래 파일들을 생성한다.

import React from 'react'
import { isArrayNull } from 'lib'

import { AccordionItem } from 'components'

import './Accordion.css'

const Accordion = ({ title, items, displayFilter }) => {
    return (
        <div className='Accordion-container'>
            <div className='Accordion-menu' onClick={displayFilter}>
                <div className='Accordion-arrow'></div>
                <div className='Accordion-title'>{title}</div>
            </div>
            <div className='Accordion-items'>
                {!isArrayNull(items) && items.map( item => {
                    return (
                        <AccordionItem key={item.value}>{item.label}</AccordionItem>
                    )
                })}
            </div>
        </div>
    )
}

export default Accordion

Accordion.js 파일을 위와 같이 작성하자!

import { isArrayNull } from 'lib'

데이터 유효성 검증을 위하여 lib 폴더에서 isArrayNull 함수를 임포트한다.

import { AccordionItem } from 'components'

AccordionItem 컴포넌트를 사용하기 위하여 임포트한다. 해당 컴포넌트는 Accordion 컴포넌트(필터 카테고리) 안에 있는 하나의 필터이다. 

{ title, items, displayFilter }

title 은 필터 카테고리의 이름이다. 예를 들면, 위치 및 주변지역, 랜드마크, 숙박 시설 유형과 같은 문자열 형태이다. items 는 필터 이름에 대한 배열이다. 예를 들면, 숙박 시설 유형 카테고리 안에 있는 게스트하우스, 호텔, 아파트 등과 같은 문자열 형태이다. displayFilter 함수는 사용자가 필터 카테고리를 클릭할때마다 실행되는 이벤트핸들러 함수이다. 

<div className='Accordion-menu' onClick={displayFilter}>
    <div className='Accordion-arrow'></div>
    <div className='Accordion-title'>{title}</div>
</div>

Accordion-menu 요소에 onClick 이벤트를 등록하고 displayFilter 이벤트핸들러 함수를 연결한다. 사용자가 Accordion-menu 요소를 클릭하면 해당 카테고리 안의 필터 목록이 열리고 닫힌다. 

<div className='Accordion-items'>
    {!isArrayNull(items) && items.map( item => {
        return (
            <AccordionItem key={item.value}>{item.label}</AccordionItem>
        )
    })}
</div>

AccordionItem 컴포넌트를 사용하여 필터목록을 화면에 렌더링한다. items 는 필터 목록이 저장된 배열이다. 

.Accordion-container{
    border-top: 1px solid lightgray;
}
.Accordion-menu{
    cursor: pointer;
    display: flex;
    justify-content: flex-start;
    align-items: center;
    user-select: none;
}
.Accordion-arrow{
    width: 20px;
    height: 20px;
    margin-right: 10px;
    background-image: url('../assets/images/down-arrow.png');
    background-size: cover;
}
.change-arrow{
    background-image: url('../assets/images/up-arrow.png');
}

.Accordion-title{
    font-size: 1.2rem;
    font-weight: bold;
    margin-top: 20px;
    margin-bottom: 20px;
    color: #1a1c1b;
}
.Accordion-items{
    display: none;
}
.expand-filter{
    display: block;
}

Accordion.css 파일을 위와 같이 작성하자! 화살표 아이콘은 div 요소의 배경 이미지로 설정하고, change-arrow 라는 클래스명을 추가하거나 제거함으로써 화살표의 스타일을 변경한다. 

필터 카테고리를 열고 닫을때 보여줄 화살표는 아래 사이트에서 다운로드 받았다. assets > images 폴더에 다운로드 받은 화살표 리소스를 추가한다. 아이콘 이름은 각각 down-arrow.png 와 up-arrow.png 로 수정하도록 한다. 

화살표 아이콘 리소스 다운로드 사이트

 

Arrow Icons – Free Vector Download, PNG, SVG, GIF

Tell us about an icon you need, and we will draw it for free in one of the existing Icons8 styles or any other (but paid). Request icon

icons8.com

 

import React from 'react'
import './AccordionItem.css'

const AccordionItem = ({ children }) => {
    return (
        <div className='AccordionItem-container'>
            <div className='AccordionItem-checker'><input type='checkbox'/></div>
            <div className='AccordionItem-filter'>{children}</div>
        </div>
    )
}

export default AccordionItem

AccordionItem.js 파일을 위와 같이 작성하자!

chidren 은 필터 이름을 의미한다. 예를 들면, 숙박 시설 유형이라는 필터 카테고리 안에 있는 게스트 하우스, 호텔, 아파트 등과 같은 문자열이다. 체크박스를 함께 렌더링하여 사용자가 특정 필터를 클릭하면 해당 필터를 반영한 호텔 목록만 다시 검색해서 보여주려고 한다. 

.AccordionItem-container{
    display: flex;
    justify-content: flex-start;

    margin-top: 15px;
    margin-bottom: 15px;
    user-select: none;
}
.AccordionItem-checker{
    margin-right: 10px;
}
.AccordionItem-checker input{
    width: 18px;
    height: 18px;
}
.AccordionItem-filter{
    color: #333333;
}

AccordionItem.css 파일을 위와 같이 작성하자!

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

components > index.js 파일에 생성한 컴포넌트를 추가로 내보낸다.

import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'

import { fetchHotelsCom, isArrayNull, handleNullObj } from 'lib'
import hotelsData from '../hotelsData'
import { HotelItem, Accordion } from 'components'

import './Hotels.css'

const Hotels = () => {
    const location = useLocation()
    const { destinationId, checkIn, checkOut, adultsNumber } = handleNullObj(location.state)
    console.log(destinationId, checkIn, checkOut, adultsNumber)

    const [hotels, setHotels] = useState([])
    const [mapObj, setMapObj] = useState(null)
    const [filters, setFilters] = useState(null)

    useEffect( async () => {
        const { results, filters } = await getHotels()
        setHotels(results)
        setFilters(filters)

        const m = L.map('map')
        setMapObj(m)
    }, [])

    const getHotels = async () => {
        // const data = await fetchHotelsCom(`https://hotels-com-provider.p.rapidapi.com/v1/hotels/search?checkin_date=${checkIn}&checkout_date=${checkOut}&sort_order=STAR_RATING_HIGHEST_FIRST&destination_id=${destinationId}&adults_number=${adultsNumber}&locale=ko_KR&currency=KRW`)
        // console.log(data)

        const {searchResults: {results}, filters } = hotelsData
        
        return { results, filters }
    }

    
    const displayLocation = (lat, lon, msg) => {
        // console.log('inside:', mapObj)

        if(mapObj){
            const map = mapObj.setView([lat, lon], 13)

            L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
                attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
            }).addTo(map)

            L.marker([lat, lon]).addTo(map)
                .bindPopup(msg)
                .openPopup()
        }
    }

    const displayFilter = (e) => {
        const target = e.target.closest('.Accordion-container')
        const arrow = target.querySelector('.Accordion-arrow')
        const items = target.querySelector('.Accordion-items')
        console.log(target)
        console.log(arrow, items)

        arrow.classList.toggle('change-arrow')
        items.classList.toggle('expand-filter')

    }

    const AccordionList = () => {
        if(filters){
            const { neighbourhood, landmarks, accommodationType, facilities, themesAndTypes, accessibility } = handleNullObj(filters)
            const filterTypes = [
                {items: handleNullObj(neighbourhood).items, title: '위치 및 주변 지역'},
                {items: handleNullObj(landmarks).items, title: '랜드마크'},
                {items: handleNullObj(accommodationType).items, title: '숙박 시설 유형'},
                {items: handleNullObj(facilities).items, title: '시설'},
                {items: handleNullObj(themesAndTypes).items, title: '테마/유형'},
                {items: handleNullObj(accessibility).items, title: '장애인 편의 시설'},
            ]

            return (
                <div>{filterTypes.map( (filterType, id) => {
                    return (
                        <Accordion key={id} title={filterType.title} items={filterType.items} displayFilter={displayFilter}/>
                    )
                })}</div>
            )
        }else{
            return <></>
        }
    }

    const getLocation = (hotel) => {
        const { name, address, coordinate } = handleNullObj(hotel)
        const { streetAddress, locality, countryName } = handleNullObj(address)
        const { lat, lon } = handleNullObj(coordinate)
        const msg = `${name}<br/>${streetAddress}, ${locality}, ${countryName}`

        return { lat, lon, msg }
    }

    return (
        <div className='Hotels-container'>
            <div className='Hotels-filtered'>
                <AccordionList/>
            </div>
            <div className='Hotels-searched'>
                <div id="map"></div>
                {!isArrayNull(hotels) && hotels.map( hotel => {
                    const { lat, lon, msg } = getLocation(hotel)
                    displayLocation(lat, lon, msg)
                    return (
                        <HotelItem hotel={hotel} key={hotel.id}/>
                    )
                })}
            </div>
        </div>
    )
}

export default Hotels

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

import { HotelItem, Accordion } from 'components'

필터 카테고리와 필터 목록을 보여주기 위하여 Accordion 컴포넌트를 추가로 임포트한다. 

const { destinationId, checkIn, checkOut, adultsNumber } = handleNullObj(location.state)

홈화면에서 전달받은 데이터가 존재하지 않을수도 있으므로 handleNullObj 함수로 location.state 가 undefined 인 경우를 대비하여 데이터 유효성 검증을 한다.

 const [filters, setFilters] = useState(null)

hotelsCom API 서버로부터 전달받은 필터 카테고리와 필터 목록을 화면에 보여주기 위하여 State Hook 을 사용하여 filters 상태를 선언한다. 

const getHotels = async () => {
    // const data = await fetchHotelsCom(`https://hotels-com-provider.p.rapidapi.com/v1/hotels/search?checkin_date=${checkIn}&checkout_date=${checkOut}&sort_order=STAR_RATING_HIGHEST_FIRST&destination_id=${destinationId}&adults_number=${adultsNumber}&locale=ko_KR&currency=KRW`)
    // console.log(data)

    const {searchResults: {results}, filters } = hotelsData

    return { results, filters }
}

API 서버로부터 데이터를 추출할때 filters 프로퍼티를 추가로 추출한다. 반환값으로는 filters 값이 추가된 객체 형태로 내보낸다. 

const { results, filters } = await getHotels()
setHotels(results)
setFilters(filters)

setFilters 함수를 사용하여 getHotels 함수로부터 반환된 filters 값으로 filters 상태값을 업데이트한다. 

const displayFilter = (e) => {
    const target = e.target.closest('.Accordion-container') // 필터 카테고리 요소
    const arrow = target.querySelector('.Accordion-arrow')  // 화살표
    const items = target.querySelector('.Accordion-items')  // 필터 목록
    console.log(target)
    console.log(arrow, items)

    arrow.classList.toggle('change-arrow')  // 화살표 아이콘 변경
    items.classList.toggle('expand-filter') // 필터 목록 열고 닫기

}

사용자가 필터 카테고리를 선택하면 실행되는 이벤트핸들러 함수이다. 필터 카테고리를 선택하면 화살표 아이콘을 변경하고, 필터 목록을 열고 닫는다. 

const AccordionList = () => {
    if(filters){
        const { neighbourhood, landmarks, accommodationType, facilities, themesAndTypes, accessibility } = handleNullObj(filters)
        const filterTypes = [
            {items: handleNullObj(neighbourhood).items, title: '위치 및 주변 지역'},
            {items: handleNullObj(landmarks).items, title: '랜드마크'},
            {items: handleNullObj(accommodationType).items, title: '숙박 시설 유형'},
            {items: handleNullObj(facilities).items, title: '시설'},
            {items: handleNullObj(themesAndTypes).items, title: '테마/유형'},
            {items: handleNullObj(accessibility).items, title: '장애인 편의 시설'},
        ]

        return (
            <div>{filterTypes.map( (filterType, id) => {
                return (
                    <Accordion key={id} title={filterType.title} items={filterType.items} displayFilter={displayFilter}/>
                )
            })}</div>
        )
    }else{
        return <></>
    }
}

AccordionList 는 필터 카테고리 목록을 화면에 렌더링하기 위한 컴포넌트이다. filters 는 상태값이므로 초기상태는 null 이고, 서버로부터 데이터를 전달받으면 같이 존재한다. 그래서 조건문을 사용하여 값이 존재하는 경우에만 화면에 필터 카테고리와 필터 목록을 보여주도록 한다. 

const { neighbourhood, landmarks, accommodationType, facilities, themesAndTypes, accessibility } = handleNullObj(filters)

filters 객체로부터 6개의 필터 카테고리를 추출한다. 

const filterTypes = [
    {items: handleNullObj(neighbourhood).items, title: '위치 및 주변 지역'},
    {items: handleNullObj(landmarks).items, title: '랜드마크'},
    {items: handleNullObj(accommodationType).items, title: '숙박 시설 유형'},
    {items: handleNullObj(facilities).items, title: '시설'},
    {items: handleNullObj(themesAndTypes).items, title: '테마/유형'},
    {items: handleNullObj(accessibility).items, title: '장애인 편의 시설'},
]

필터 카테고리 필드에서 items 프로퍼티만 사용할 것이므로 객체의 배열로 데이터를 재가공한다. 필터 카테고리 이름을 의미하는 title 프로퍼티를 추가로 설정한다. 필터 카테고리에 해당하는 neighbourhood, landmarks, accommodationType, facilities, themesAndTypes, accessibility 값이 undefined 인 경우를 대비하여 handleNullObj 함수로 데이터 유효성 검증을 하도록 한다. 

<div>{filterTypes.map( (filterType, id) => {
    return (
        <Accordion key={id} title={filterType.title} items={filterType.items} displayFilter={displayFilter}/>
    )
})}</div>

필터 카테고리 배열을 순회하면서 Accordion 컴포넌트를 사용하여 필터 카테고리와 필터 목록을 화면에 렌더링한다. 

const getLocation = (hotel) => {
    const { name, address, coordinate } = handleNullObj(hotel)
    const { streetAddress, locality, countryName } = handleNullObj(address)
    const { lat, lon } = handleNullObj(coordinate)
    const msg = `${name}<br/>${streetAddress}, ${locality}, ${countryName}`

    return { lat, lon, msg }
}

호텔목록을 지도에 표시하기 위하여 위도, 경도, 호텔 주소 정보를 추출하는 코드를 getLocation 이라는 함수로 빼낸다.

<div className='Hotels-filtered'>
    <AccordionList/>
</div>

필터 카테고리 목록을 화면에 렌더링한다.

const { lat, lon, msg } = getLocation(hotel)
displayLocation(lat, lon, msg)
return (
    <HotelItem hotel={hotel} key={hotel.id}/>
)

호텔에 대한 위치 정보를 추출하기 위하여 getLocation 함수를 사용한다. 

.Hotels-container{
    width: 80%;
    margin: 0 auto;

    display: flex;
    /* flex-wrap: wrap; */
    justify-content: center;
    align-items: flex-start;
}
.Hotels-filtered{
    width: 30%;
    margin-right: 1%;
    /* border: 1px solid red; */
}
.Hotels-searched{
    width: 69%;
    /* border: 1px solid blue; */
}
#map { height: 400px; }

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

필터 카테고리가 추가된 화면
필터 목록이 열린 화면

 

 

이제 사용자가 선택한 필터로 호텔을 재검색하는 기능을 구현해보자!

import React from 'react'
import { isArrayNull } from 'lib'

import { AccordionItem } from 'components'

import './Accordion.css'

const Accordion = ({ title, items, displayFilter, searchHotelsWithFilter, querystring }) => {
    return (
        <div className='Accordion-container'>
            <div className='Accordion-menu' onClick={displayFilter}>
                <div className='Accordion-arrow'></div>
                <div className='Accordion-title'>{title}</div>
            </div>
            <div className='Accordion-items'>
                {!isArrayNull(items) && items.map( item => {
                    return (
                        <AccordionItem key={item.value} value={item.value} searchHotelsWithFilter={searchHotelsWithFilter} querystring={querystring}>{item.label}</AccordionItem>
                    )
                })}
            </div>
        </div>
    )
}

export default Accordion

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

{ title, items, displayFilter, searchHotelsWithFilter, querystring }

Accordion 컴포넌트의 props 에 searchHotelsWithFilter 함수와 querystring 이라는 문자열이 추가되었다. 모두 Accordion 컴포넌트에서 사용하는 것이 아니라 자식 컴포넌트인 AccordionItem 컴포넌트로 전달만 해준다.

 <AccordionItem key={item.value} value={item.value} searchHotelsWithFilter={searchHotelsWithFilter} querystring={querystring}>{item.label}</AccordionItem>

AccordionItem 컴포넌트의 props에 searchHotelsWithFilter 함수와 querystring 이라는 문자열, 그리고 value 를 추가로 전달한다. 

import React from 'react'
import './AccordionItem.css'

const AccordionItem = ({ children, searchHotelsWithFilter, value, querystring }) => {
    return (
        <div className='AccordionItem-container'>
            <div className='AccordionItem-checker' onClick={() => searchHotelsWithFilter(querystring, value)}><input type='checkbox'/></div>
            <div className='AccordionItem-filter'>{children}</div>
        </div>
    )
}

export default AccordionItem

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

{ children, searchHotelsWithFilter, value, querystring }

AccordionItem 컴포넌트의 props에 searchHotelsWithFilter 함수와 querystring 이라는 문자열, 그리고 value 를 추가로 전달받는다. 

<div className='AccordionItem-checker' onClick={() => searchHotelsWithFilter(querystring, value)}><input type='checkbox'/></div>

사용자가 필터 카테고리에서 적용하고자 하는 필터를 선택할때마다 click 이벤트가 발생하면서 searchHotelsWithFilter 함수가 실행된다. 해당 함수의 인자로 querystring 과 value 값이 전달된다. querystring 은 hotels.com API 서버에서 데이터를 가져올때 URL 에 들어가는 쿼리 파라미터이고, value 는 쿼리 파라미터에 대한 값이다.

 

아래와 같이 accommodation_ids, theme_ids 와 같은 문자열이 querystring 이다. 20%2C15%2C5%2C1 이 value 에 해당된다.

쿼리 파라미터와 해당하는 값

 

import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'

import { fetchHotelsCom, isArrayNull, handleNullObj } from 'lib'
import hotelsData from '../hotelsData'
import { HotelItem, Accordion, Button } from 'components'

import './Hotels.css'

const Hotels = () => {
    let query = {}
    const location = useLocation()
    const { destinationId, checkIn, checkOut, adultsNumber } = handleNullObj(location.state)
    console.log(destinationId, checkIn, checkOut, adultsNumber)

    const BASE_URL = `https://hotels-com-provider.p.rapidapi.com/v1/hotels/search?checkin_date=${checkIn}&checkout_date=${checkOut}&sort_order=STAR_RATING_HIGHEST_FIRST&destination_id=${destinationId}&adults_number=${adultsNumber}&locale=ko_KR&currency=KRW`

    const [hotels, setHotels] = useState([])
    const [mapObj, setMapObj] = useState(null)
    const [filters, setFilters] = useState(null)
    const [queryURL, setQueryURL] = useState(null)

    useEffect( async () => {
        console.log(BASE_URL)
        const { results, filters } = await getHotels(BASE_URL)
        setHotels(results)
        setFilters(filters)

        const m = L.map('map')
        setMapObj(m)
    }, [])

    useEffect( async () => {
        console.log('query: ', queryURL)
        console.log(BASE_URL)
        let url = BASE_URL
        
        for(let prop in queryURL){
            const queryvalue = encodeURIComponent(queryURL[prop].join(','))
            url += `&${prop}=${queryvalue}`
            console.log(prop, queryvalue)
        }
        console.log('total url: ', url)

        const { results } = await getHotels(url)
        setHotels(results)
    }, [queryURL])

    const getHotels = async (url) => {
        // const data = await fetchHotelsCom(url)
        // console.log(data)
        // const {searchResults: {results}, filters } = data

        const {searchResults: {results}, filters } = hotelsData
        
        return { results, filters }
    }

    
    const displayLocation = (lat, lon, msg) => {
        // console.log('inside:', mapObj)

        if(mapObj){
            const map = mapObj.setView([lat, lon], 13)

            L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
                attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
            }).addTo(map)

            L.marker([lat, lon]).addTo(map)
                .bindPopup(msg)
                .openPopup()
        }
    }

    const displayFilter = (e) => {
        const target = e.target.closest('.Accordion-container')
        const arrow = target.querySelector('.Accordion-arrow')
        const items = target.querySelector('.Accordion-items')
        console.log(target)
        console.log(arrow, items)

        arrow.classList.toggle('change-arrow')
        items.classList.toggle('expand-filter')

    }

    const searchHotelsWithFilter = (querystring, value) => {
        console.log('search with filter', querystring, value)
        query = {...query, [querystring]: [...query[querystring] ?? [] , value]}
        console.log('query in filter function: ', query)

    }

    const searchHotels = () => {
        setQueryURL(query)
        console.log('seach ')
    }

    const AccordionList = () => {
        if(filters){
            const { neighbourhood, landmarks, accommodationType, facilities, themesAndTypes, accessibility } = handleNullObj(filters)
            const filterTypes = [
                {items: handleNullObj(neighbourhood).items, title: '위치 및 주변 지역', querystring: 'landmark_id'},
                {items: handleNullObj(landmarks).items, title: '랜드마크', querystring: 'landmark_id'},
                {items: handleNullObj(accommodationType).items, title: '숙박 시설 유형', querystring: 'accommodation_ids'},
                {items: handleNullObj(facilities).items, title: '시설', querystring: 'amenity_ids'},
                {items: handleNullObj(themesAndTypes).items, title: '테마/유형', querystring: 'theme_ids'},
                {items: handleNullObj(accessibility).items, title: '장애인 편의 시설', querystring: 'amenity_ids'},
            ]

            return (
                <div>{filterTypes.map( (filterType, id) => {
                    return (
                        <Accordion key={id} title={filterType.title} items={filterType.items} displayFilter={displayFilter} searchHotelsWithFilter={searchHotelsWithFilter} querystring={filterType.querystring}/>
                    )
                })}</div>
            )
        }else{
            return <></>
        }
    }

    const getLocation = (hotel) => {
        const { name, address, coordinate } = handleNullObj(hotel)
        const { streetAddress, locality, countryName } = handleNullObj(address)
        const { lat, lon } = handleNullObj(coordinate)
        const msg = `${name}<br/>${streetAddress}, ${locality}, ${countryName}`

        return { lat, lon, msg }
    }

    return (
        <div className='Hotels-container'>
            <div className='Hotels-filtered'>
                <AccordionList/>
                <Button handleClick={searchHotels}>호텔 검색</Button>
            </div>
            <div className='Hotels-searched'>
                <div id="map"></div>
                {!isArrayNull(hotels) && hotels.map( hotel => {
                    const { lat, lon, msg } = getLocation(hotel)
                    displayLocation(lat, lon, msg)
                    return (
                        <HotelItem hotel={hotel} key={hotel.id}/>
                    )
                })}
            </div>
        </div>
    )
}

export default Hotels

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

import { HotelItem, Accordion, Button } from 'components'

사용자가 선택한 필터 조건으로 호텔을 재검색하기 위하여 Button 컴포넌트를 추가로 임포트한다. 

let query = {}

사용자가 선택한 필터 조건들을 한꺼번에 기억하기 위한 변수이다.

 const BASE_URL = `https://hotels-com-provider.p.rapidapi.com/v1/hotels/search?checkin_date=${checkIn}&checkout_date=${checkOut}&sort_order=STAR_RATING_HIGHEST_FIRST&destination_id=${destinationId}&adults_number=${adultsNumber}&locale=ko_KR&currency=KRW`

hotels.com API 서버에서 호텔 목록 데이터를 가져오기 위한 기본 URL 이다. 목적지 ID, 체크인과 체크아웃 날짜, 투숙인원이 기본적으로 들어간다. 

const [queryURL, setQueryURL] = useState(null)

queryURL 이라는 상태를 선언한다. queryURL 은 사용자가 선택한 모든 필터 조건들로 호텔을 재검색할때 기본 URL 에 덧붙여 새로운 URL 을 만드는데 사용된다. 

const { results, filters } = await getHotels(BASE_URL)

호텔 목록 페이지가 처음 보여질때 기본 URL 을 사용하여 검색한 호텔 목록 데이터를 가져온다. getHotels 함수에 데이터를 가져올 서버의 URL 인자를 전달하도록 변경하였다.

useEffect( async () => {
    let url = BASE_URL

    for(let prop in queryURL){
        const queryvalue = encodeURIComponent(queryURL[prop].join(','))
        url += `&${prop}=${queryvalue}`
    }
    console.log('total url: ', url)

    const { results } = await getHotels(url)
    setHotels(results)
}, [queryURL])

또다른 Effect Hook 을 사용하였다. 해당 코드는 사용자가 필터조건을 적용하여 호텔을 재검색할때 실행되는 부분이다. Effect Hook 의 두번째 인자에 의하여 queryURL 상태가 변경된 경우에만 해당 코드가 실행된다. 

let url = BASE_URL

사용자가 선택한 필터 조건으로 새로운 URL 을 생성하기 위하여 기본 URL 을 참조한다.

for(let prop in queryURL){
    const queryvalue = encodeURIComponent(queryURL[prop].join(','))
    url += `&${prop}=${queryvalue}`
}

반복문(for ~ in 문)을 사용하여 queryURL 객체로부터 모든 프로퍼티를 조회한다. prop 은 accommodation_ids, theme_ids, amenity_ids 와 같이 URL 에서 쿼리 파라미터를 의미한다.

queryURL[prop] 은 배열이며, 특정 필터 카테고리에서 사용자가 선택한 필터 조건들에 대한 value 값들이 들어있다. 예를 들면, [25, 3, 30] 과 같다. 해당 배열의 value 값들은 URL 의 쿼리 파라미터 값으로 추가하기 위하여 문자열의 join 함수를 사용하여 콤마로 구분된 문자열로 변경해준다. 그러면 '25, 3, 30' 과 같다. 

하지만 아래에서 살펴보면 쿼리 파라미터에 대한 값에 %2C 와 같은 문자열이 보인다. 이는 필터조건들이 20, 8, 15, 5, 1 과 같을때 서버로 보내기 전 URL 을 인코딩한 것이다. 즉, %2C 는 콤마(,) 를 인코딩한 것이다. 이렇게 URL 을 인코딩하기 위하여 자바스크립트에서 제공하는 encodeURIComponent 를 사용하였다.  

url += `&${prop}=${queryvalue}`

& 기호를 사용하여 기본 URL 에 필터가 적용된 새로운 URL 을 생성한다. 

const { results } = await getHotels(url)
setHotels(results)

필터가 적용된 새로운 URL 을 이용하여 호텔 목록을 새로 검색한다. 그런 다음 서버로부터 가져온 결과인 results 값을 사용하여 hotels 상태를 업데이트한다.

const getHotels = async (url) => {
    // const data = await fetchHotelsCom(url)
    // console.log(data)
    // const {searchResults: {results}, filters } = data

    const {searchResults: {results}, filters } = hotelsData
    return { results, filters }
}

getHotels 함수에 접속할 서버 URL 을 인자로 전달하도록 수정한다. 

const searchHotelsWithFilter = (querystring, value) => {
    console.log('search with filter', querystring, value)
    query = {...query, [querystring]: [...query[querystring] ?? [] , value]}
    console.log('query in filter function: ', query)
}

사용자가 필터를 선택할때마다 실행되는 함수이다. 

query = {...query, [querystring]: [...query[querystring] ?? [] , value]}

query 는 사용자가 선택한 필터조건들에 대한 정보를 담고 있는 객체이다. 사용자가 필터를 추가할때마다 query 객체를 변경한다. 스프레드 연산자(...)을 이용하여 기존 객체에서 특정 프로퍼티 값만 변경한다. querystring 은 문자열이므로 프로퍼티를 설정할때 대괄호([])를 사용해야 한다. 

querystring 은 앞에서도 언급했듯이 사용자가 선택한 필터조건에 대한 URL 쿼리 파라미터를 의미한다. 예를 들면, accommodation_ids, theme_ids, amenity_ids 와 같다. query[querystring] 은 필터 조건들에 대한 배열이다. 예를 들어 [12, 3, 30] 과 같은 필터조건들에 대한 value 값이다. 마찬가지로 스프레드 연산자(...)를 사용하여 기존 배열에 사용자가 선택한 새로운 필터조건에 대한 value 값을 추가한다.

const searchHotels = () => {
    setQueryURL(query)
    console.log('seach ')
}

호텔 목록 페이지에서 [호텔 검색] 버튼을 클릭하면 실행되는 이벤트핸들러 함수이다. 사용자가 선택한 모든 필터조건들이 저장된 query 값으로 queryURL 상태를 업데이트한다. 

const filterTypes = [
    {items: handleNullObj(neighbourhood).items, title: '위치 및 주변 지역', querystring: 'landmark_id'},
    {items: handleNullObj(landmarks).items, title: '랜드마크', querystring: 'landmark_id'},
    {items: handleNullObj(accommodationType).items, title: '숙박 시설 유형', querystring: 'accommodation_ids'},
    {items: handleNullObj(facilities).items, title: '시설', querystring: 'amenity_ids'},
    {items: handleNullObj(themesAndTypes).items, title: '테마/유형', querystring: 'theme_ids'},
    {items: handleNullObj(accessibility).items, title: '장애인 편의 시설', querystring: 'amenity_ids'},
]

filterTypes 은 필터 카테고리와 필터조건들에 대한 정보를 담고 있는 배열이다. 해당 배열요소는 객체 형태이며, 객체의 프로퍼티에 querystring 이 추가되었다. 

<Accordion key={id} 
	   title={filterType.title} 
           items={filterType.items} 
           displayFilter={displayFilter} 
           searchHotelsWithFilter={searchHotelsWithFilter} 
           querystring={filterType.querystring}
/>

Accordion 컴포넌트에 searchHotelsWithFilter 함수와 querystring 문자열이라는 props 를 추가하였다. 

<Button handleClick={searchHotels}>호텔 검색</Button>

사용자가 선택한 필터로 호텔을 다시 검색하기 위하여 검색 버튼을 추가한다. 

 

hotels.com 페이지에서 게스트하우스 필터로 호텔을 검색한 화면

 

hotels.com 클론 페이지에서 게스트하우스 필터로 호텔을 검색한 화면

 

이제 숙박 시설 등급에 대한 필터 기능을 추가해보자!

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

import React from 'react'
import './StarRatingFilter.css'

import { isArrayNull } from 'lib'

const StarRatingFilter = ({ title, items, searchHotelsWithFilter, querystring }) => {
    const reversedItems = items.sort((a, b) => {
        return a.value - b.value;
      })
    
    return (
        <div className='StarRatingFilter-container'>
            <div className='StarRatingFilter-title'>{title}</div>
            <div className='StarRatingFilter-btns'>
                {!isArrayNull(reversedItems) && reversedItems.map( (item, idx) => {
                    return (
                        <div className='StarRatingFilter-rating' key={item.value} onClick={() => searchHotelsWithFilter(querystring, item.value)}>{item.value}</div>
                    )
                })}
            </div>
        </div>
    )
}

export default StarRatingFilter

StarRatingFilter.js 파일을 위와 같이 생성하자! Accordion 컴포넌트와 형태는 거의 동일하다. 다만 숙박 등급에 대한 필터 목록 (items) 가 거꾸로 되어 있어서 아래와 같은 코드로 오름차순으로 정렬하였다. 

const reversedItems = items.sort((a, b) => {
  return a.value - b.value;
})

숙박 시설 등급에 대한 필터 목록을 오름차순으로 정렬한다.

.StarRatingFilter-container{
    border-top: 1px solid lightgray;
    margin-bottom: 15px;
}
.StarRatingFilter-title{
    font-size: 1.2rem;
    font-weight: bold;
    margin-top: 20px;
    margin-bottom: 20px;
    color: #1a1c1b;
}
.StarRatingFilter-btns{
    display: flex;
    justify-content: center;
    align-items: center;
}
.StarRatingFilter-rating{
    width: 100px;
    height: 40px;
    line-height: 40px;
    text-align: center;
    margin-right: 10px;
    font-size: 1.2rem;

    border-radius: 20px;
    cursor: pointer;
    user-select: none;
    box-sizing: border-box;
    color: #125ca5;
    border: 1px solid #125ca5;
}

StarRatingFilter.css 파일을 위와 같이 생성하자!

export { default as Input } from './Input'
export { default as Button } from './Button'
export { default as Caption } from './Caption'
export { default as HotelItem } from './HotelItem'
export { default as Accordion } from './Accordion'
export { default as AccordionItem } from './AccordionItem'
export { default as StarRatingFilter } from './StarRatingFilter'

components > index.js 파일에 StarRatingFilter 컴포넌트를 추가로 내보낸다. 

import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'

import { fetchHotelsCom, isArrayNull, handleNullObj } from 'lib'
import hotelsData from '../hotelsData'
import { HotelItem, Accordion, Button, StarRatingFilter } from 'components'

import './Hotels.css'

const Hotels = () => {
    let query = {}
    const location = useLocation()
    const { destinationId, checkIn, checkOut, adultsNumber } = handleNullObj(location.state)
    console.log(destinationId, checkIn, checkOut, adultsNumber)

    const BASE_URL = `https://hotels-com-provider.p.rapidapi.com/v1/hotels/search?checkin_date=${checkIn}&checkout_date=${checkOut}&sort_order=STAR_RATING_HIGHEST_FIRST&destination_id=${destinationId}&adults_number=${adultsNumber}&locale=ko_KR&currency=KRW`

    const [hotels, setHotels] = useState([])
    const [mapObj, setMapObj] = useState(null)
    const [filters, setFilters] = useState(null)
    const [queryURL, setQueryURL] = useState(null)

    useEffect( async () => {
        console.log(BASE_URL)
        // 새로고침해도 필터값이 적용되려면 초기 렌더링시에는 BASE_URL 을 적용하고 queryURL 상태가 null 이 아니면 queryURL 을 적용하면 되지 않을까?
        const { results, filters } = await getHotels(BASE_URL)
        setHotels(results)
        setFilters(filters)

        const m = L.map('map')
        setMapObj(m)
    }, [])

    useEffect( async () => {
        console.log('query: ', queryURL)
        console.log(BASE_URL)
        let url = BASE_URL
        
        for(let prop in queryURL){
            const queryvalue = encodeURIComponent(queryURL[prop].join(','))
            url += `&${prop}=${queryvalue}`
            console.log(prop, queryvalue)
        }
        console.log('total url: ', url)

        const { results } = await getHotels(url)
        setHotels(results)
    }, [queryURL])

    const getHotels = async (url) => {
        // const data = await fetchHotelsCom(url)
        // console.log(data)
        // const {searchResults: {results}, filters } = data

        const {searchResults: {results}, filters } = hotelsData
        
        return { results, filters }
    }

    
    const displayLocation = (lat, lon, msg) => {
        // console.log('inside:', mapObj)

        if(mapObj){
            const map = mapObj.setView([lat, lon], 13)

            L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
                attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
            }).addTo(map)

            L.marker([lat, lon]).addTo(map)
                .bindPopup(msg)
                .openPopup()
        }
    }

    const displayFilter = (e) => {
        const target = e.target.closest('.Accordion-container')
        const arrow = target.querySelector('.Accordion-arrow')
        const items = target.querySelector('.Accordion-items')
        console.log(target)
        console.log(arrow, items)

        arrow.classList.toggle('change-arrow')
        items.classList.toggle('expand-filter')

    }

    const searchHotelsWithFilter = (querystring, value) => {
        console.log('search with filter', querystring, value)
        query = {...query, [querystring]: [...query[querystring] ?? [] , value]}
        console.log('query in filter function: ', query)

    }

    const searchHotels = () => {
        setQueryURL(query)
        console.log('seach ')
    }

    const FilterList = () => {
        if(filters){
            const { neighbourhood, landmarks, accommodationType, facilities, themesAndTypes, accessibility, starRating } = handleNullObj(filters)
            const starRatingTypes = {items: handleNullObj(starRating).items, title: '숙박 시설 등급', querystring: 'star_rating_ids'}
            const filterTypes = [
                {items: handleNullObj(neighbourhood).items, title: '위치 및 주변 지역', querystring: 'landmark_id'},
                {items: handleNullObj(landmarks).items, title: '랜드마크', querystring: 'landmark_id'},
                {items: handleNullObj(accommodationType).items, title: '숙박 시설 유형', querystring: 'accommodation_ids'},
                {items: handleNullObj(facilities).items, title: '시설', querystring: 'amenity_ids'},
                {items: handleNullObj(themesAndTypes).items, title: '테마/유형', querystring: 'theme_ids'},
                {items: handleNullObj(accessibility).items, title: '장애인 편의 시설', querystring: 'amenity_ids'},
            ]

            return (
                <>
                    <StarRatingFilter title={starRatingTypes.title} items={starRatingTypes.items} searchHotelsWithFilter={searchHotelsWithFilter} querystring={starRatingTypes.querystring}/>
                    <div>{filterTypes.map( (filterType, id) => {
                        return (
                            <Accordion key={id} title={filterType.title} items={filterType.items} displayFilter={displayFilter} searchHotelsWithFilter={searchHotelsWithFilter} querystring={filterType.querystring}/>
                        )
                })}</div>
                </>
            )
        }else{
            return <></>
        }
    }

    const getLocation = (hotel) => {
        const { name, address, coordinate } = handleNullObj(hotel)
        const { streetAddress, locality, countryName } = handleNullObj(address)
        const { lat, lon } = handleNullObj(coordinate)
        const msg = `${name}<br/>${streetAddress}, ${locality}, ${countryName}`

        return { lat, lon, msg }
    }

    return (
        <div className='Hotels-container'>
            <div className='Hotels-filtered'>
                <FilterList/>
                <Button handleClick={searchHotels}>호텔 검색</Button>
            </div>
            <div className='Hotels-searched'>
                <div id="map"></div>
                {!isArrayNull(hotels) && hotels.map( hotel => {
                    const { lat, lon, msg } = getLocation(hotel)
                    displayLocation(lat, lon, msg)
                    return (
                        <HotelItem hotel={hotel} key={hotel.id}/>
                    )
                })}
            </div>
        </div>
    )
}

export default Hotels

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

import { HotelItem, Accordion, Button, StarRatingFilter } from 'components'

숙박 시설 등급에 대한 필터 목록을 보여주기 위하여 StarRatingFilter 컴포넌트를 추가로 임포트한다. 

const FilterList = () => {
        if(filters){
            const { neighbourhood, landmarks, accommodationType, facilities, themesAndTypes, accessibility, starRating } = handleNullObj(filters)
            const starRatingTypes = {items: handleNullObj(starRating).items, title: '숙박 시설 등급', querystring: 'star_rating_ids'}
            const filterTypes = [
                {items: handleNullObj(neighbourhood).items, title: '위치 및 주변 지역', querystring: 'landmark_id'},
                {items: handleNullObj(landmarks).items, title: '랜드마크', querystring: 'landmark_id'},
                {items: handleNullObj(accommodationType).items, title: '숙박 시설 유형', querystring: 'accommodation_ids'},
                {items: handleNullObj(facilities).items, title: '시설', querystring: 'amenity_ids'},
                {items: handleNullObj(themesAndTypes).items, title: '테마/유형', querystring: 'theme_ids'},
                {items: handleNullObj(accessibility).items, title: '장애인 편의 시설', querystring: 'amenity_ids'},
            ]

            return (
                <>
                    <StarRatingFilter title={starRatingTypes.title} items={starRatingTypes.items} searchHotelsWithFilter={searchHotelsWithFilter} querystring={starRatingTypes.querystring}/>
                    <div>{filterTypes.map( (filterType, id) => {
                        return (
                            <Accordion key={id} title={filterType.title} items={filterType.items} displayFilter={displayFilter} searchHotelsWithFilter={searchHotelsWithFilter} querystring={filterType.querystring}/>
                        )
                })}</div>
                </>
            )
        }else{
            return <></>
        }
    }

AccordionList 컴포넌트를 FilterList 라는 이름으로 변경하였다. 

const { neighbourhood, landmarks, accommodationType, facilities, themesAndTypes, accessibility, starRating } = handleNullObj(filters)
const starRatingTypes = {items: handleNullObj(starRating).items, title: '숙박 시설 등급', querystring: 'star_rating_ids'}

filters 데이터에서 starRating 프로퍼티를 추가로 조회한다. starRating 데이터를 이용하여 데이터를 재가공한다. starRatingTypes 객체는 해당 필터 카테고리에 대한 title 과 querystring 프로퍼티를 가지고 있다. 

<StarRatingFilter title={starRatingTypes.title} items={starRatingTypes.items} searchHotelsWithFilter={searchHotelsWithFilter} querystring={starRatingTypes.querystring}/>

숙박 시설 등급에 대한 필터 카테고리를 화면에 보여준다. 

<FilterList/>

AccordionList 컴포넌트 대신에 FilterList 컴포넌트를 렌더링한다. 

숙박 시설 등급 필터가 적용된 모습

 

728x90