Rails React Turo의 풀스택 클론
Caro는 P2P 자동차 렌탈 서비스('자동차용 에어비앤비')인 Turo의 풀스택 클론입니다.
Caro는 Google Map
viewport
및 기타 URL search parameters
기반으로 자동차 색인을 검색하는 동적 기능을 갖춘 Google의 자동 제안 Places Search Box
및 Google Maps
기능을 갖추고 있습니다.
사용자에게는 다음과 같은 CRUD 기능이 있습니다.
Caro에는 전체 사용자 인증도 있습니다.
그것을 탐험 하시겠습니까? 여기!
아니면 빨리 보세요.
Github
링크드인
포트폴리오
Caro에서 사용자는 다음을 수행할 수 있습니다.
Google Map
확인하고 다음을 기준으로 검색 결과 범위를 좁히세요.Google Map
의 viewport
변경Places Search Box
에서 위치 검색이 프로젝트는 다음 기술로 구현됩니다.
CSS
스타일, AJAX
기술 및 Redux state
사용하는 React
및 JavaScript
프런트엔드JBuilder
사용하는 Ruby on Rails
백엔드Maps JavaScript API
, Places API
및 Geocoding API
AWS
와 앱에서 이미지를 사용하는 Active Storage
Flatpickr
Heroku
Webpack
npm
bcrypt
및 has_secure_password
Active Record 매크로 방법 사용자는 필터와 Google Map
viewport
영역을 사용하여 동적으로 자동차를 검색하고 결과를 좁힐 수 있습니다.
Google 자동 제안 Places Search Box
으로 시작하는 검색 예:
도시/경험 타일로 시작하는 검색 예시:
CarsSearchIndex
는 필터의 변경 사항( URL search params
로 공유되고 state variables
로 저장됨)을 수신하고 해당 필터를 기반으로 자동차를 가져오는 백엔드 호출을 트리거합니다. 가져오기 요청을 발송하기 전에 날짜를 UTC로 정규화합니다.
CarsSearchIndex/index.js
useEffect ( ( ) => {
if (
minPricing !== undefined &&
maxPricing !== undefined &&
bounds &&
experienceType &&
( superhostFilter === false || superhostFilter === true ) &&
searchPageFromDate &&
searchPageUntilDate
) {
dispatch (
fetchCars ( {
minPricing ,
maxPricing ,
bounds ,
superhostFilter : superhostFilter ,
experienceType ,
tripStart : handleDateChange ( searchPageFromDate ) ,
tripEnd : handleDateChange ( searchPageUntilDate ) ,
} )
) ;
}
} , [
minPricing ,
maxPricing ,
bounds ,
superhostFilter ,
experienceType ,
searchPageFromDate ,
searchPageUntilDate ,
dispatch ,
] ) ;
특히 Google 지도를 드래그하거나 확대/축소하면 Google Map
의 viewport
나타내는 위도/경도 값 집합인 bounds
값을 변경하여 자동차 가져오기 요청을 전달하게 됩니다. 사용자가 Places Search Box
통해 검색하기로 선택한 경우 검색 작업은 지도에 위치와 Google 제안 viewport
좌표를 제공하며 이로 인해 bounds
도 변경됩니다. viewport
좌표를 사용하면 동적 확대/축소 수준이 가능합니다. 특정 주소('페리 빌딩')는 덜 구체적인 주소('샌프란시스코')보다 Google Map
에서 더 가깝게 확대됩니다.
CarsSearchIndex/index.js
const mapEventHandlers = useMemo (
( ) => ( {
click : ( event ) => {
const search = new URLSearchParams ( event . latLng . toJSON ( ) ) . toString ( ) ;
} ,
idle : ( map ) => {
const newBounds = map . getBounds ( ) . toUrlValue ( ) ;
if ( newBounds !== bounds ) {
setBounds ( newBounds ) ;
}
} ,
} ) ,
[ history ]
) ;
그런 다음 백엔드는 기준을 충족하는 자동차 목록을 필터링합니다. 주목해야 할 흥미로운 필터는 요청한 여행 날짜 동안 예약되지 않은 차량만 선택하는 마지막 필터입니다. 각 자동차와 관련된 여행을 확인하고 자동차 여행과 요청한 여행 날짜 사이에 가능한 중복 시나리오가 발생하지 않는 경우에만 자동차를 선택합니다.
cars_controller.rb
def index
@cars = Car . includes ( :host ) . includes ( :reviews ) . includes ( :trips )
@cars = @cars . in_bounds ( bounds ) if bounds
@cars = @cars . where ( daily_rate : price_range ) if price_range
if superhost_filter === 'true'
@cars = @cars . where ( host_id : User . where ( is_superhost : true ) . pluck ( :id ) )
else
@cars
end
if experience_filter === 'all'
@cars
else
@cars = @cars . where ( category : experience_filter )
end
if ! date_range
@cars
elsif date_range === [ "" , "" ] || date_range === [ "Invalid Date" , "Invalid Date" ]
@cars
else
@cars = @cars . where . not ( id : Trip . where ( "(start_date <= ? AND end_date >= ?) OR (start_date <= ? AND end_date >= ?) OR (start_date >= ? AND end_date <= ?)" , Date . parse ( date_range [ 0 ] ) , Date . parse ( date_range [ 0 ] ) , Date . parse ( date_range [ 1 ] ) , Date . parse ( date_range [ 1 ] ) , Date . parse ( date_range [ 0 ] ) , Date . parse ( date_range [ 1 ] ) )
. select ( :car_id ) )
end
end
결과 자동차 목록은 마커 배치를 위해 Google Map
로 전달되고, 각각을 CarSearchIndexItem
에 매핑하는 CarList
구성요소로 전달됩니다.
Caro를 사용하면 사용자는 탐색 표시줄 검색줄과 스플래시 페이지의 검색 표시줄을 통해 모든 페이지에서 Places Search Box
검색을 시작할 수 있습니다. 검색 작업은 검색 입력을 사용하여 자동차 가져오기를 전달하는 자동차 검색 색인으로의 점프를 트리거합니다.
문제는 자동차 검색 색인 페이지에서 이미 검색을 시작할 때 자동차를 강제로 가져오는 방법에 관한 것입니다. 다른 경우와 달리 여기에서는 페이지와 지도가 이미 렌더링되었습니다.
내 초기 솔루션은 현재 URL 위치를 확인하고 해당 매개변수가 이미 검색 페이지에 있는 것으로 감지되면 URL search params
푸시하는 SearchLine
검색 구성요소에서 사용자 정의 handleSearchClick
활용하는 것이었습니다. 다른 경우에는 검색 기준을 전달하는 데 localStorage
사용되었습니다.
SearchLine/index.js
const handleSearchClick = ( ) => {
if ( location . pathname . match ( / ^/cars/?$|^(?!/cars/d)/cars/?.* / ) ) {
setSearchPageFromDate ( from ) ;
setSearchPageUntilDate ( until ) ;
setSearchPageWhere ( where ) ;
history . push ( `/cars?coords= ${ coords . lat } , ${ coords . lng } ` ) ;
} else {
localStorage . setItem ( "fromDate" , from ) ;
localStorage . setItem ( "untilDate" , until ) ;
localStorage . setItem ( "where" , where ) ;
localStorage . setItem ( "coords" , JSON . stringify ( coords ) ) ;
history . push ( "/cars/" ) ;
}
} ;
그 동안 CarMap
URL의 변경 사항을 수신하고 적절한 키를 찾은 경우 값을 구문 분석하고 Google Map
의 중심 및 확대/축소 수준을 변경했습니다.
CarMap/index.js
const location = useLocation ( ) ;
useEffect ( ( ) => {
if ( map ) {
const urlParams = new URLSearchParams ( location . search ) ;
if ( urlParams . get ( "coords" ) ) {
const coords = urlParams
. get ( "coords" )
. split ( "," )
. map ( ( coord ) => parseFloat ( coord ) ) ;
const newLatLng = new window . google . maps . LatLng ( coords [ 0 ] , coords [ 1 ] ) ;
map . setCenter ( newLatLng ) ;
map . setZoom ( 14 ) ;
}
}
} , [ location ] ) ;
Google Map
뷰포트의 변경으로 인해 위에 설명된 대로 bounds
변경이 촉발되었으며, 이로 인해 새 파견자가 자동차를 가져오고 검색 결과가 새로 고쳐졌습니다.
모든 경우에 localStorage
대신 URL search params
사용하도록 프런트엔드 검색 코드를 리팩터링했습니다. 검색어를 URL에 저장하면 친구와 쉽게 검색 매개변수를 공유하고 백스페이스를 눌러 동일한 검색어로 돌아갈 수 있는 등의 이점이 있습니다.
이것이 내 코드의 모습입니다. 이는 몇 가지 리팩터링을 반영합니다: localStorage
-> URL search params
, 정적 확대/축소 수준 -> viewport
좌표 및 '검색하려면 클릭' -> '검색하려면 입력 상자 종료'.
SearchLine/index.js, location input box example
const existingSearchParams = new URLSearchParams ( location . search ) ;
const handlePlaceOnSelect = ( address ) => {
setWhere ( address ) ;
geocodeByAddress ( address )
. then ( ( results ) => {
if ( results && results . length > 0 ) {
getLatLng ( results [ 0 ] ) . then ( ( latLng ) => {
setCoords ( latLng ) ;
existingSearchParams . set ( "coords" , ` ${ latLng . lat } , ${ latLng . lng } ` ) ;
existingSearchParams . delete ( "zoom" ) ;
//only update date params if they are different from the ones already in URL
let paramsDatesArr = [ ] ;
if ( existingSearchParams . get ( "dates" ) ) {
paramsDatesArr = existingSearchParams
. get ( "dates" )
. split ( "," )
. map ( ( dateStr ) =>
new Date ( dateStr ) . toLocaleDateString ( "en-US" )
) ;
}
const dateRangeArr = dateRange . map ( ( date ) =>
date . toLocaleDateString ( "en-US" )
) ;
if (
paramsDatesArr [ 0 ] !== dateRangeArr [ 0 ] ||
paramsDatesArr [ 1 ] !== dateRangeArr [ 1 ]
) {
existingSearchParams . set ( "dates" , dateRange ) ;
}
//set viewport from Google Place's API callback
if ( results [ 0 ] . geometry . viewport ) {
const viewportCoords = results [ 0 ] . geometry . viewport . toJSON ( ) ;
existingSearchParams . set (
"viewport" ,
` ${ viewportCoords . east } , ${ viewportCoords . west } , ${ viewportCoords . north } , ${ viewportCoords . south } `
) ;
}
existingSearchParams . set ( "location" , address ) ;
history . push ( {
pathname : "/cars" ,
search : existingSearchParams . toString ( ) ,
} ) ;
} ) ;
} else {
console . error ( "No results found for the address:" , address ) ;
}
} )
. catch ( ( error ) => console . error ( "Geocoding error:" , error ) ) ;
setValidPlace ( true ) ;
} ;
리팩터링을 수용하기 위해 이제 Google Map
URL search params
에서 필요한 정보를 가져오고 viewport
좌표를 구문 분석하여 지도에 표시할 영역을 결정합니다.
CarMap/index.js
const urlParams = new URLSearchParams ( location . search ) ;
const coordsParams = urlParams . get ( "coords" ) ;
const zoomParams = urlParams . get ( "zoom" ) ;
const viewportParams = urlParams . get ( "viewport" ) ;
useEffect ( ( ) => {
if ( map ) {
if ( coordsParams ) {
const coords = coordsParams
. split ( "," )
. map ( ( coord ) => parseFloat ( coord ) ) ;
const newLatLng = new window . google . maps . LatLng ( coords [ 0 ] , coords [ 1 ] ) ;
map . setCenter ( newLatLng ) ;
if ( viewportParams ) {
const bounds = new window . google . maps . LatLngBounds ( ) ;
const coords = viewportParams
. split ( "," )
. map ( ( coord ) => parseFloat ( coord . trim ( ) ) ) ;
const west = coords [ 0 ] ;
const east = coords [ 1 ] ;
const north = coords [ 2 ] ;
const south = coords [ 3 ] ;
bounds . extend ( new window . google . maps . LatLng ( north , west ) ) ;
bounds . extend ( new window . google . maps . LatLng ( south , east ) ) ;
map . fitBounds ( bounds ) ;
} else if ( zoomParams ) {
map . setZoom ( parseInt ( zoomParams ) ) ;
} else {
map . setZoom ( 15 ) ;
}
}
}
} , [ coordsParams , zoomParams , viewportParams , map ] ) ;
Caro는 여러 테이블에 걸쳐 사용자, 자동차 및 기타 데이터를 저장하고 테이블을 조인합니다. 프런트엔드 페이지에는 여러 테이블의 데이터를 결합하는 구성 요소가 있습니다. 백엔드 model associations
과 JBuilder
백엔드 호출을 최소화하면서 각 구성 요소에 올바른 정보를 보내는 데 도움이 됩니다.
예를 들어, 즐겨찾기 페이지는 현재 사용자에 대해서만 모든 즐겨찾기를 가져오고 관련 데이터를 열심히 로드합니다.
favorites_controller.rb
def index
if current_user
@favorites = Favorite . includes ( :driver ) . includes ( :car ) . where ( driver_id : current_user . id ) . order ( updated_at : :desc )
else
head :no_content
end
end
그런 다음 JBuilder
자동차 사진 및 호스트 정보와 함께 각 즐겨찾기 및 즐겨찾기에 연결된 자동차에 대한 정보를 포함하는 응답을 구성합니다.
_favorite.json.jbuilder
json . extract! favorite ,
:id ,
:driver_id ,
:car_id ,
:created_at
json . car do
json . extract! favorite . car , :id ,
:make ,
:model ,
:year ,
:mpg ,
:doors_count ,
:seats_count ,
:category ,
:automatic ,
:description ,
:guidelines ,
:daily_rate ,
:location ,
:active ,
:host_id ,
:created_at ,
:avg_cleanliness_rating ,
:avg_maintenance_rating ,
:avg_communication_rating ,
:avg_convenience_rating ,
:avg_accuracy_rating ,
:trips_count ,
:reviews_count ,
:city
if favorite . car . photos . attached?
photos = [ ]
favorite . car . photos . each do | photo |
photos << url_for ( photo )
end
json . photos_url photos
end
json . host do
json . extract! favorite . car . host , :id , :first_name , :last_name , :approved_to_drive , :is_superhost , :is_clean_certified , :email , :phone_number , :created_at , :updated_at , :trips_count , :user_rating , :hosted_cars_count
end
end
각 즐겨찾기 정보에는 관련 자동차 및 자동차의 호스트 세부정보가 포함되어 있으므로 각 즐겨찾기 자동차 타일을 렌더링하는 FavoriteIndexItem
구성 요소는 자체 데이터를 가져올 필요가 없고 오히려 상위 FavoritesPage
에서 favorite
로 props를 통해 필요한 정보를 가져옵니다.
FavoritesPage/index.js
...
< div id = "favs-index-container" >
{ favorites && favorites . map ( ( favorite , idx ) => (
< FavoriteIndexItem key = { idx } favorite = { favorite } />
) ) }
</ div >
. . .
결과적으로 Redux state
다음과 같습니다.
다른 주목할만한 기능을 구현하려면 소스 파일을 살펴보세요.
향후 개선 사항은 다음과 같습니다.