Rails React Turo のフルスタック クローン
Caro は、ピアツーピアレンタカーサービス (「自動車用 Airbnb」) である Turo のフルスタック クローンです。
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
フロントエンドJBuilder
を使用してバックエンドの応答を彫刻するRuby on Rails
バックエンドMaps JavaScript API
、 Places API
、 Geocoding API
AWS
と、アプリ内で画像を使用するためのActive Storage
Flatpickr
Heroku
Webpack
npm
bcrypt
およびhas_secure_password
アクティブ レコード マクロ メソッドユーザーは動的に車を検索し、フィルターや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
を表す lat/lng 値のセットである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 に保存すると、検索パラメータを友人と簡単に共有したり、Backspace キーを押して同じ検索クエリに戻ることができるなどの利点があります。
これが私のコードが今どのように見えるかです。これはいくつかのリファクタリングを反映しています: 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
として小道具を介して必要な情報を取得します。
FavoritesPage/index.js
...
< div id = "favs-index-container" >
{ favorites && favorites . map ( ( favorite , idx ) => (
< FavoriteIndexItem key = { idx } favorite = { favorite } />
) ) }
</ div >
. . .
その結果、 Redux state
次のようになります。
他の注目すべき機能の実装については、ソース ファイルを参照してください。
今後の改善点は次のとおりです。