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
如下所示:
查看源文件以实现其他值得注意的功能:
即将推出的改进包括: