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 MapPlaces Search BoxDieses Projekt wird mit folgenden Technologien umgesetzt:
React und JavaScript Frontend mit CSS Stil, AJAX -Techniken und Redux stateRuby 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: