Rails React Fullstack-Klon von Turo
Caro ist ein Fullstack-Klon von Turo, einem Peer-to-Peer-Autovermietungsdienst („Airbnb für Autos“).
Caro verfügt über das Autosuggest Places Search Box
von Google und Google Maps
mit dynamischer Funktionalität zum Durchsuchen des Fahrzeugindex basierend auf dem Google Map
viewport
und anderen URL search parameters
.
Benutzer verfügen über CRUD-Funktionen für:
Caro verfügt außerdem über eine vollständige Benutzerauthentifizierung.
Entdecken Sie es? Hier!
Oder schauen Sie sich das Quick an
Github
Linkedin
Portfolio
In Caro können Benutzer:
Google Map
mit eingezeichneten Suchergebnissen für Autos an und grenzen Sie die Suchergebnisse ein nach:viewport
von Google Map
Places Search Box
Dieses Projekt wird mit folgenden Technologien umgesetzt:
React
und JavaScript
Frontend mit CSS
Stil, AJAX
-Techniken und Redux state
Ruby on Rails
-Backend mit JBuilder
zur Gestaltung von Backend-AntwortenMaps JavaScript API
, Places API
und Geocoding API
zur Aktivierung der Karten- und StandortsucheAWS
zum Hosten von Fahrzeug- und Benutzerbildern und Active Storage
für die Verwendung von Bildern in AppsFlatpickr
zur Auswahl des ReisedatumsHeroku
für App-HostingWebpack
zum Bündeln und Transpilieren des Quellcodesnpm
zum Verwalten von Projektabhängigkeitenbcrypt
und has_secure_password
Active Record-Makromethode für die Benutzerauthentifizierung Ein Benutzer kann dynamisch nach Autos suchen und die Ergebnisse mithilfe von Filtern und Google Map
viewport
eingrenzen.
Beispielsuche beginnend mit dem Google-Autosuggest Places Search Box
:
Beispielsuche beginnend mit den Stadt-/Erlebniskacheln:
Der CarsSearchIndex
hört auf Änderungen in Filtern (geteilt als URL search params
und gespeichert als state variables
) und löst einen Backend-Aufruf aus, um Autos basierend auf diesen Filtern abzurufen. Vor dem Absenden der Abrufanforderung werden außerdem die Daten auf UTC normalisiert.
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 ,
] ) ;
Insbesondere löst das Ziehen oder Zoomen der Google-Karte das Versenden einer Anforderung zum Abholen von Autos aus, indem der Wert von bounds
geändert wird. Hierbei handelt es sich um eine Reihe von Lat/Lng-Werten, die das viewport
von Google Map
darstellen. Wenn sich ein Benutzer für die Suche über das Places Search Box
entscheidet, gibt die Suchaktion einen Standort und von Google vorgeschlagene viewport
an die Karte weiter, was auch zu einer Änderung der bounds
führt. Die Verwendung von viewport
ermöglicht eine dynamische Zoomstufe – bestimmte Adressen („Ferry Building“) werden auf der Google Map
näher herangezoomt als weniger spezifische Adressen („San Francisco“).
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 ]
) ;
Das Backend filtert dann die Liste der Autos heraus, die die Kriterien erfüllen. Ein interessanter Filter ist der letzte, der nur Autos auswählt, die zu den gewünschten Reisedaten nicht ausgebucht sind. Es überprüft die Fahrten, die jedem Auto zugeordnet sind, und wählt das Auto nur dann aus, wenn keine möglichen Überschneidungsszenarien zwischen den Fahrten des Autos und den angeforderten Reisedaten auftreten.
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
Die resultierende Fahrzeugliste wird zur Platzierung von Markierungen an Google Map
und an die CarList
Komponente übergeben, die jedes Fahrzeug einem CarSearchIndexItem
zuordnet.
Mit Caro können Benutzer von jeder Seite aus über eine Navigationsleisten-Suchzeile und die Suchleiste der Begrüßungsseite eine Suche Places Search Box
starten. Die Suchaktion löst einen Sprung zum Autosuchindex aus, der mithilfe der Sucheingaben einen Autoabruf auslöst.
Die Herausforderung bestand darin, wie man den Abruf eines Autos erzwingen kann, wenn man eine Suche startet, wenn man sich BEREITS auf der Indexseite der Autosuche befindet. Im Gegensatz zu anderen Fällen sind hier die Seite und die Karte bereits gerendert.
Meine ursprüngliche Lösung bestand darin, ein benutzerdefiniertes handleSearchClick
in der SearchLine
Suchkomponente zu verwenden, das die aktuelle URL-Position überprüfte und URL search params
weiterleitete, wenn festgestellt wurde, dass sie sich bereits auf der Suchseite befanden. In anderen Fällen wurde localStorage
zur Weitergabe von Suchkriterien verwendet.
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/" ) ;
}
} ;
Währenddessen hörte CarMap
auf Änderungen in der URL, analysierte den Wert, wenn es den entsprechenden Schlüssel fand, und änderte die Mitte und Zoomstufe der 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 ] ) ;
Die Änderung im Ansichtsfenster von Google Map
löste wie oben beschrieben eine Änderung der bounds
aus, was zu einem neuen Versand zum Abholen von Autos führte und die Suchergebnisse aktualisierte.
Ich habe meinen Frontend-Suchcode umgestaltet, um in allen Fällen URL search params
anstelle von localStorage
zu verwenden. Das Speichern der Suchabfrage in der URL bietet Vorteile wie die Möglichkeit, die Suchparameter einfach mit Freunden zu teilen und die Rücktaste zu drücken, um zur gleichen Suchabfrage zurückzukehren.
So sieht mein Code jetzt aus. Dies spiegelt einige Refaktoren wider: localStorage
-> URL search params
, statische Zoomstufe -> viewport
und „Zum Suchen klicken“ -> „Eingabefeld zum Suchen verlassen“.
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 ) ;
} ;
Um dem Refactor Rechnung zu tragen, ruft Google Map
nun die benötigten Informationen aus URL search params
ab und analysiert die Koordinaten viewport
um zu bestimmen, welcher Bereich auf der Karte angezeigt werden soll.
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 speichert Benutzer-, Fahrzeug- und andere Daten in mehreren Tabellen und verknüpft Tabellen. Frontend-Seiten verfügen über Komponenten, die Daten aus mehreren Tabellen kombinieren. Backend- model associations
und JBuilder
helfen dabei, die richtigen Informationen an jede Komponente zu senden und gleichzeitig Backend-Aufrufe zu minimieren.
Beispielsweise ruft die Favoritenseite alle Favoriten nur für den aktuellen Benutzer ab und lädt die zugehörigen Daten eifrig.
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
erstellt dann eine Antwort, die Informationen zu jedem Favoriten und zu dem mit dem Favoriten verknüpften Auto sowie Fotos und Hostinformationen des Autos enthält.
_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
Da die Informationen zu jedem Favoriten die zugehörigen Fahrzeug- und Fahrzeughostdetails umfassen, muss die FavoriteIndexItem
Komponente, die jede bevorzugte Fahrzeugkachel rendert, nicht ihre eigenen Daten abrufen, sondern ruft die erforderlichen Informationen über Requisiten als favorite
von der übergeordneten FavoritesPage
ab.
FavoritesPage/index.js
...
< div id = "favs-index-container" >
{ favorites && favorites . map ( ( favorite , idx ) => (
< FavoriteIndexItem key = { idx } favorite = { favorite } />
) ) }
</ div >
. . .
Infolgedessen sieht Redux state
wie folgt aus:
Sehen Sie sich die Quelldateien zur Implementierung anderer bemerkenswerter Funktionen an:
Zu den bevorstehenden Verbesserungen gehören: