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 APIAWSと、アプリ内で画像を使用するためのActive StorageFlatpickrHerokuWebpacknpmbcryptおよび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次のようになります。
他の注目すべき機能の実装については、ソース ファイルを参照してください。
今後の改善点は次のとおりです。