Turo 的 Rails React 全端克隆
Caro 是 Turo 的全端克隆,Turo 是一種點對點汽車租賃服務(「汽車 Airbnb」)。
Caro 採用 Google 的自動建議Places Search Box
和Google Maps
,具有基於Google Map
viewport
和其他URL search parameters
搜尋汽車索引的動態功能。
用戶具有以下 CRUD 功能:
Caro 還擁有完整的用戶身份驗證。
探索一下嗎?這裡!
或者,快速觀看
吉圖布
領英
資料夾
在 Caro 中,使用者能夠:
Google Map
,並透過以下方式縮小搜尋結果範圍:Google Map
的viewport
Places Search Box
搜尋位置本計畫採用以下技術實施:
CSS
樣式、 AJAX
技術和Redux state
React
和JavaScript
前端Ruby on Rails
後端與JBuilder
一起塑造後端回應Maps JavaScript API
、 Places API
和Geocoding API
AWS
用於託管汽車和使用者圖像, Active Storage
用於在應用程式中使用圖像Flatpickr
用於選擇旅行日期Heroku
Webpack
來捆綁和轉譯原始碼npm
管理專案依賴bcrypt
和has_secure_password
Active Record 巨集方法使用者可以使用濾鏡和Google Map
viewport
區域動態搜尋汽車並縮小結果範圍。
以 Google autosuggest 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 地圖會透過更改bounds
的值來觸發調度汽車獲取請求,bounds 是一組代表Google Map
viewport
的 lat/lng 值。如果使用者選擇透過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
以放置標記,並傳遞到CarList
元件,該元件將每個清單對應到CarSearchIndexItem
。
Caro 允許使用者透過導覽列搜尋線和啟動頁面的搜尋列從任何頁面啟動Places Search Box
搜尋。搜尋操作觸發跳到汽車搜尋索引,該索引使用搜尋輸入調度汽車取得。
挑戰在於,當汽車搜尋索引頁面已存在時,如何在啟動搜尋時強制取得汽車。與其他情況不同,這裡頁面和地圖已經呈現。
我最初的解決方案是在SearchLine
搜尋元件上使用自訂的handleSearchClick
,該元件檢查目前 URL 位置,並在偵測到 URL 搜尋參數已位於搜尋頁面上時推送URL search params
。在其他情況下, 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
變化,這導致了一個新的調度來獲取汽車,從而刷新了搜尋結果。
我重構了前端搜尋程式碼,在所有情況下都使用URL search params
而不是localStorage
。將搜尋查詢儲存在 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
組件不需要獲取自己的數據,而是通過 props 作為favorite
從其父級FavoritesPage
獲取所需的資訊。
FavoritesPage/index.js
...
< div id = "favs-index-container" >
{ favorites && favorites . map ( ( favorite , idx ) => (
< FavoriteIndexItem key = { idx } favorite = { favorite } />
) ) }
</ div >
. . .
結果, Redux state
如下所示:
查看原始檔案以實現其他值得注意的功能:
即將推出的改進包括: